diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33896f62..353c2aa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,14 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + windows-unsafe-boundary: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Test the Windows FFI allowance guard + run: scripts/ci/check-windows-unsafe-boundary_tests.sh + - name: Check the Windows FFI allowance boundary + run: scripts/ci/check-windows-unsafe-boundary.sh module-ci: uses: tinyhumansai/.github/.github/workflows/module-ci.yml@4adc1080fb495098e7af1aade82ed7e6e3c69bb7 with: diff --git a/AGENTS.md b/AGENTS.md index b7750263..1ce4ff08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,12 +145,15 @@ Use standard `rustfmt` output and Rust 2024 idioms. Do not hand-format around `impl Into` at boundaries; return owned, concrete types. - Keep the public surface minimal: default to private, and export deliberately from `src/lib.rs`. -- `unsafe` is forbidden across the workspace by `[workspace.lints]` in the root +- `unsafe` is denied across the workspace by `[workspace.lints]` in the root `Cargo.toml`, which every crate inherits with `[lints] workspace = true`. - **No crate relaxes it.** ADR 0003 reserved `tinybox-linux` as the one that - might; ADR 0005 records why it did not need to. If you find yourself wanting - `unsafe`, check first whether an existing tool already does the dangerous part - correctly — that is how the namespace backend avoided it. + **Only `crates/tinybox-jail/src/windows.rs` relaxes it**, with one file-scoped + `#![allow(unsafe_code)]` for the `AppContainer` FFI (ADR 0003); + `scripts/ci/check-windows-unsafe-boundary.sh` fails CI on any other spelling + of a relaxation. ADR 0005 records why `tinybox-linux` did not need one. If + you find yourself wanting `unsafe`, check first whether an existing tool + already does the dangerous part correctly — that is how the namespace + backend avoided it. ### Errors diff --git a/Cargo.lock b/Cargo.lock index 6bc61453..3fb3ebfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,7 +59,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -70,9 +70,15 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.8" @@ -87,7 +93,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.3", ] [[package]] @@ -98,21 +104,22 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bitflags" -version = "2.13.2" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "blake3" -version = "1.8.7" +version = "1.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" dependencies = [ + "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", - "cpufeatures 0.3.1", + "cpufeatures 0.3.0", ] [[package]] @@ -169,7 +176,7 @@ dependencies = [ "maybe-owned", "rustix", "rustix-linux-procfs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "winx", ] @@ -187,9 +194,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.10.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", "jobserver", @@ -199,9 +206,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.5" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -211,9 +218,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "clap" -version = "4.6.7" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -221,9 +228,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.7" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -233,21 +240,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.7" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.3", ] [[package]] name = "clap_lex" -version = "1.1.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" @@ -290,27 +297,27 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.5.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-deque" -version = "0.8.8" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -318,18 +325,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.21" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.23" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crypto-common" @@ -377,6 +384,23 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "enumflags2" version = "0.7.12" @@ -399,9 +423,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" @@ -410,7 +434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -443,19 +467,18 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.15" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] name = "flate2" -version = "1.1.10" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", - "zlib-rs", ] [[package]] @@ -472,7 +495,7 @@ checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" dependencies = [ "io-lifetimes 2.0.4", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -481,6 +504,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -489,7 +523,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.3", ] [[package]] @@ -498,6 +532,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.34" @@ -551,10 +591,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 6.0.0", - "wasm-bindgen", ] [[package]] @@ -628,6 +666,95 @@ dependencies = [ "typenum", ] +[[package]] +name = "icu_casemap" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a6f143e9349267b80ae071a6ec25d4797c45756d8b2671a8b24b7b25b81ab94" +dependencies = [ + "icu_casemap_data", + "icu_collections", + "icu_locale_core", + "icu_properties", + "icu_provider", + "potential_utf", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_casemap_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71a5c321208537711696af55bc2ab5dad939e4f6e0aef92dd6a2555deceedf3a" + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "serde", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ignore" version = "0.4.33" @@ -646,9 +773,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.2" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -661,7 +788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" dependencies = [ "io-lifetimes 3.0.1", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -728,9 +855,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.191" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libsqlite3-sys" @@ -749,11 +876,17 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" -version = "0.4.34" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "maybe-owned" @@ -769,9 +902,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "miniz_oxide" -version = "0.9.1" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", "simd-adler32", @@ -779,9 +912,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.4" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -841,6 +974,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -909,6 +1053,32 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rawzip" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9999039f74cd73c03158cddfe4362991703c74efe5ce5c8073026f80a5e48c" + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "regex-automata" version = "0.4.18" @@ -967,15 +1137,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1038,6 +1208,31 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + [[package]] name = "serde" version = "1.0.229" @@ -1065,14 +1260,25 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.154" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1083,9 +1289,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.2" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -1108,7 +1314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", + "cpufeatures 0.3.0", "digest 0.11.3", ] @@ -1142,9 +1348,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.4" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "socket2" @@ -1153,7 +1359,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1168,6 +1374,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1193,15 +1405,26 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.9" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "tar" version = "0.4.46" @@ -1223,41 +1446,41 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "thiserror" -version = "2.0.21" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.21" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.3", ] [[package]] name = "tinybox-bus" -version = "0.2.3" +version = "0.2.4" dependencies = [ + "schemars", "serde", "serde_json", - "tinystoragedrivers-types", ] [[package]] name = "tinybox-cli" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "clap", @@ -1276,10 +1499,11 @@ dependencies = [ [[package]] name = "tinybox-core" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "serde", + "serde_json", "thiserror", "tinybox-bus", "tokio", @@ -1287,7 +1511,7 @@ dependencies = [ [[package]] name = "tinybox-docker" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "ipnet", @@ -1302,7 +1526,7 @@ dependencies = [ [[package]] name = "tinybox-drivers" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "log", @@ -1321,11 +1545,14 @@ dependencies = [ [[package]] name = "tinybox-host" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "cap-std", "command-group", + "futures-executor", + "futures-timer", + "futures-util", "nix 0.31.3", "tempfile", "tinybox-bus", @@ -1336,7 +1563,7 @@ dependencies = [ [[package]] name = "tinybox-jail" -version = "0.2.3" +version = "0.2.4" dependencies = [ "landlock", "log", @@ -1356,7 +1583,7 @@ dependencies = [ [[package]] name = "tinybox-linux" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "tempfile", @@ -1367,7 +1594,7 @@ dependencies = [ [[package]] name = "tinybox-microvm" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "serde", @@ -1380,7 +1607,7 @@ dependencies = [ [[package]] name = "tinybox-module" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "log", @@ -1409,7 +1636,7 @@ dependencies = [ [[package]] name = "tinybox-openshell" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "serde_json", @@ -1419,7 +1646,7 @@ dependencies = [ [[package]] name = "tinybox-ssh" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "tempfile", @@ -1433,7 +1660,7 @@ dependencies = [ [[package]] name = "tinybox-sync" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "blake3", @@ -1447,11 +1674,13 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.3.6" +version = "0.3.12" dependencies = [ "async-trait", "flate2", + "icu_casemap", "libc", + "rawzip", "serde", "serde_json", "sha2 0.10.9", @@ -1462,13 +1691,13 @@ dependencies = [ "tokio", "toml", "tracing", + "unicode-normalization", "ureq", - "zip", ] [[package]] name = "tinybus-macros" -version = "0.3.6" +version = "0.3.12" dependencies = [ "proc-macro2", "quote", @@ -1477,7 +1706,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.3.6" +version = "0.3.12" dependencies = [ "async-trait", "serde", @@ -1644,11 +1873,28 @@ dependencies = [ "thiserror", ] +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" -version = "1.54.1" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -1668,14 +1914,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.3", ] [[package]] name = "toml" -version = "1.1.8+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap", "serde_core", @@ -1688,27 +1934,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.2+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.1.5+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.3+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tracing" @@ -1741,12 +1987,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "typed-path" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" - [[package]] name = "typenum" version = "1.20.1" @@ -1765,9 +2005,18 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.27" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] [[package]] name = "untrusted" @@ -1777,9 +2026,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.5.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" dependencies = [ "base64", "flate2", @@ -1794,9 +2043,9 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" dependencies = [ "base64", "http", @@ -1810,6 +2059,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1896,7 +2151,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.3", "wasm-bindgen-shared", ] @@ -1921,9 +2176,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.11" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] @@ -1950,7 +2205,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1971,25 +2226,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2007,31 +2244,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -2040,96 +2260,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "1.0.4" @@ -2143,7 +2315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ "bitflags", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2152,6 +2324,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "xattr" version = "1.6.1" @@ -2162,6 +2340,29 @@ dependencies = [ "rustix", ] +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.63" @@ -2182,33 +2383,67 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", + "synstructure", +] + [[package]] name = "zeroize" -version = "1.9.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] -name = "zip" -version = "9.1.1" +name = "zerotrie" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ - "crc32fast", - "flate2", - "getrandom 0.4.3", - "indexmap", - "memchr", - "nix 0.31.3", - "typed-path", - "windows-sys 0.61.2", + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", ] [[package]] -name = "zlib-rs" -version = "0.6.8" +name = "zerovec-derive" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index 618be4f8..864224b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ members = [ exclude = ["vendor"] [workspace.package] -version = "0.2.3" +version = "0.2.4" edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" @@ -38,13 +38,13 @@ categories = ["development-tools", "virtualization"] [workspace.dependencies] # TinyBus defines the message types, interface macro, and frozen module ABI used # by the bus adapter. Socket and CLI features are unnecessary here. -tinybus = { version = ">=0.3.6, <0.4", default-features = false, features = [ +tinybus = { version = ">=0.3.12, <0.4", default-features = false, features = [ "macros", "modules", ] } # The module-side SDK owns the isolated runtime and exports the ABI entrypoints # required by TinyBus's dynamic loader. -tinybus-module = ">=0.3.6, <0.4" +tinybus-module = ">=0.3.12, <0.4" # Derive macros for the crate-wide error type in `tinybox-core`. thiserror = "2" @@ -59,29 +59,31 @@ serde_json = "1" # The model types are serializable; see `tinybox-core`'s dependency comment. serde = { version = "1", features = ["derive"] } # Internal path dependencies between workspace members. -tinybox-bus = { path = "crates/tinybox-bus", version = "0.2.3" } -tinybox-core = { path = "crates/tinybox-core", version = "0.2.3" } -tinybox-host = { path = "crates/tinybox-host", version = "0.2.3" } -tinybox-jail = { path = "crates/tinybox-jail", version = "0.2.3" } -tinybox-linux = { path = "crates/tinybox-linux", version = "0.2.3" } -tinybox-microvm = { path = "crates/tinybox-microvm", version = "0.2.3" } -tinybox-docker = { path = "crates/tinybox-docker", version = "0.2.3" } -tinybox-openshell = { path = "crates/tinybox-openshell", version = "0.2.3" } -tinybox-ssh = { path = "crates/tinybox-ssh", version = "0.2.3" } -tinybox-sync = { path = "crates/tinybox-sync", version = "0.2.3" } -# Pin the storage contract crates to the canonical TSD v2.1 release commit. +tinybox-bus = { path = "crates/tinybox-bus", version = "0.2.4" } +tinybox-core = { path = "crates/tinybox-core", version = "0.2.4" } +tinybox-host = { path = "crates/tinybox-host", version = "0.2.4" } +tinybox-jail = { path = "crates/tinybox-jail", version = "0.2.4" } +tinybox-linux = { path = "crates/tinybox-linux", version = "0.2.4" } +tinybox-microvm = { path = "crates/tinybox-microvm", version = "0.2.4" } +tinybox-docker = { path = "crates/tinybox-docker", version = "0.2.4" } +tinybox-openshell = { path = "crates/tinybox-openshell", version = "0.2.4" } +tinybox-ssh = { path = "crates/tinybox-ssh", version = "0.2.4" } +tinybox-sync = { path = "crates/tinybox-sync", version = "0.2.4" } +# Pin the storage contract crates to the canonical TSD v2.1.2 release commit, +# which carries the zstd advisory fix. tinystoragedrivers-types = { version = "2.1.2", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } tinystoragedrivers-api = { version = "2.1.2", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } tinystoragedrivers-busproxy = { version = "2.1.2", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } tinystoragedrivers = { version = "2.1.2", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb", default-features = false } tinystoragedrivers-testkit = { version = "2.1.2", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } tinystoragedrivers-document = { version = "2.1.2", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } -tinybox-drivers = { path = "crates/tinybox-drivers", version = "0.2.3" } +tinybox-drivers = { path = "crates/tinybox-drivers", version = "0.2.4" } # Lints apply to every crate in the workspace via `[lints] workspace = true`. # CI runs clippy with `-D warnings`, so anything set to "warn" here fails CI. [workspace.lints.rust] -unsafe_code = "forbid" +# Keep this overridable so the isolated Windows FFI module can opt in locally. +unsafe_code = "deny" missing_docs = "warn" missing_debug_implementations = "warn" unreachable_pub = "warn" diff --git a/crates/tinybox-bus/Cargo.toml b/crates/tinybox-bus/Cargo.toml index f8a879f2..e48423dc 100644 --- a/crates/tinybox-bus/Cargo.toml +++ b/crates/tinybox-bus/Cargo.toml @@ -10,15 +10,16 @@ repository.workspace = true [dependencies] # The bus boundary contains data only; serialization is its sole dependency. serde.workspace = true +# OpenHuman config operations share this contract type for schema generation. +schemars = "1" # Only the `storage` feature: pure collection and legacy-body vocabulary. serde_json = { workspace = true, optional = true } -tinystoragedrivers-types = { workspace = true, optional = true } [features] default = [] # `collections()` and pure v1-to-v2 body converters. Off by default so the # contract's default dependency closure stays serde only; a host enables it. -storage = ["dep:serde_json", "dep:tinystoragedrivers-types"] +storage = ["dep:serde_json"] [dev-dependencies] serde_json.workspace = true diff --git a/crates/tinybox-bus/src/event.rs b/crates/tinybox-bus/src/event.rs index a6fc757c..8ff256eb 100644 --- a/crates/tinybox-bus/src/event.rs +++ b/crates/tinybox-bus/src/event.rs @@ -6,6 +6,15 @@ use serde::{Deserialize, Serialize}; +/// One process-scoped sandbox observation sent by the `TinyBox` module. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcessEvent { + /// Resource and process identifiers chosen by the caller. + pub process: crate::ProcessRef, + /// What the sandbox observed about this process. + pub event: SandboxEvent, +} + /// Hosts and ports an allowlisted box may reach. /// /// Hostnames are resolved outside the box and pinned; metadata endpoints are diff --git a/crates/tinybox-bus/src/event_tests.rs b/crates/tinybox-bus/src/event_tests.rs index c40de745..d2298a42 100644 --- a/crates/tinybox-bus/src/event_tests.rs +++ b/crates/tinybox-bus/src/event_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::NetworkPolicy; +use crate::{CONTRACT_VERSION, NetworkPolicy, SIGNALS, names}; #[test] fn events_keep_their_wire_labels() -> Result<(), serde_json::Error> { @@ -26,6 +26,28 @@ fn events_keep_their_wire_labels() -> Result<(), serde_json::Error> { Ok(()) } +#[test] +fn process_events_keep_their_correlation_and_signal_wire_shape() -> Result<(), serde_json::Error> { + let event = ProcessEvent { + process: crate::ProcessRef { + resource: crate::ResourceId("resource-1".into()), + process: crate::ResourceId("process-2".into()), + }, + event: SandboxEvent::Started, + }; + assert_eq!( + serde_json::to_value(&event)?, + serde_json::json!({ + "process": {"resource": "resource-1", "process": "process-2"}, + "event": {"event": "started"} + }) + ); + assert_eq!(names::PROCESS_EVENT, "ProcessEvent"); + assert_eq!(SIGNALS, &["ProcessEvent"]); + assert_eq!(CONTRACT_VERSION, (2, 3)); + Ok(()) +} + #[test] fn an_allowlist_policy_round_trips_and_old_policies_still_parse() -> Result<(), serde_json::Error> { let policy = NetworkPolicy::Allowlist(EgressAllowlist { diff --git a/crates/tinybox-bus/src/grants.rs b/crates/tinybox-bus/src/grants.rs index 13258b10..b1d976c0 100644 --- a/crates/tinybox-bus/src/grants.rs +++ b/crates/tinybox-bus/src/grants.rs @@ -1,8 +1,9 @@ //! Host-configured filesystem grant discovery and protected-path vocabulary. +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// Configuration used to discover toolchain and explicit filesystem grants. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(default)] pub struct LocalGrantConfig { /// Include existing toolchain homes and git configuration. diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 274ff7ee..7b228e0d 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -23,7 +23,7 @@ pub use jail::{JailEnforcement, NativeJailConfig, NativeJailRequest, NativeJailR mod shell_execution; mod shell_selection; -pub use shell_execution::ShellExecRequest; +pub use shell_execution::{RunNativeShellRequest, RunNativeShellResult, ShellExecRequest}; pub use shell_selection::{SelectShellRequest, ShellFlavor, ShellInvocation}; mod stdio; @@ -39,7 +39,7 @@ mod policy; pub use policy::{DockerPolicyReport, DockerPolicyRequest}; mod event; -pub use event::{DenialKind, EgressAllowlist, LimitKind, SandboxEvent}; +pub use event::{DenialKind, EgressAllowlist, LimitKind, ProcessEvent, SandboxEvent}; mod files; pub use files::{ @@ -83,8 +83,11 @@ pub use files::{ /// `AnalyzeShell.hidden_execution` policy signal. Generic shell structure facts /// and transport helpers remain. /// 2.1 adds separate resource-aware native jail request members without -/// changing the 2.0 Rust request shapes. -pub const CONTRACT_VERSION: (u32, u32) = (2, 1); +/// changing the 2.0 Rust request shapes. 2.2 adds process-scoped lifecycle +/// events for module-owned executions and timeout observations. 2.3 adds +/// module-owned native-shell orchestration through completion or yield, and +/// the `openshell` sandbox backend for `Create`. +pub const CONTRACT_VERSION: (u32, u32) = (2, 3); /// Minimum contract version required for resource-aware native jail members. pub const NATIVE_JAIL_RESOURCES_VERSION: (u32, u32) = (2, 1); @@ -205,6 +208,8 @@ pub mod names { pub const START_SHELL_EXEC: &str = "StartShellExec"; /// Wire name of the `StartNativeShellExec` member. pub const START_NATIVE_SHELL_EXEC: &str = "StartNativeShellExec"; + /// Wire name of the module-owned native-shell lifecycle member. + pub const RUN_NATIVE_SHELL: &str = "RunNativeShell"; /// Wire name of the `ValidateDockerPolicy` member. pub const VALIDATE_DOCKER_POLICY: &str = "ValidateDockerPolicy"; /// Wire name of the `StartStdio` member. @@ -221,8 +226,13 @@ pub mod names { pub const STDIO_STATUS: &str = "StdioStatus"; /// Wire name of the 2.1 resource-aware jailed exec member. pub const START_JAILED_EXEC_WITH_RESOURCES: &str = "StartJailedExecWithResources"; + /// Wire name of the process-scoped sandbox lifecycle signal. + pub const PROCESS_EVENT: &str = "ProcessEvent"; } +/// Supported signals in this contract. +pub const SIGNALS: &[&str] = &[names::PROCESS_EVENT]; + /// Supported methods in this contract, including the original discovery /// operation. Derived from [`names`]; the order is stable within this major. pub const METHODS: &[&str] = &[ @@ -268,6 +278,7 @@ pub const METHODS: &[&str] = &[ names::KILL_STDIO, names::STDIO_STATUS, names::START_JAILED_EXEC_WITH_RESOURCES, + names::RUN_NATIVE_SHELL, ]; /// Facts about the detected native directory-jail backend. diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index 3e42c923..bbc45bb8 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -16,6 +16,14 @@ fn command_optional_fields_default_when_absent() -> Result<(), serde_json::Error Ok(()) } +#[test] +fn local_grant_config_exposes_a_json_schema() -> Result<(), serde_json::Error> { + let schema = schemars::schema_for!(LocalGrantConfig); + let schema = serde_json::to_value(schema)?; + assert!(schema.get("properties").is_some()); + Ok(()) +} + #[test] fn create_environment_defaults_and_workspace_round_trips() -> Result<(), serde_json::Error> { let request: CreateRequest = serde_json::from_str( @@ -51,7 +59,7 @@ fn docker_run_vocabulary_is_typed_bounded_and_additive() -> Result<(), serde_jso assert_eq!(METHODS[28], "CleanupDocker"); assert_eq!(METHODS[29], "RunDockerWithoutWorkspace"); assert_eq!(METHODS[33], "StartNativeShellExec"); - assert_eq!(CONTRACT_VERSION, (2, 1)); + assert_eq!(CONTRACT_VERSION, (2, 3)); assert_eq!(MAX_DOCKER_RUN_TIMEOUT_MS, 86_400_000); let serialized = serde_json::to_value(&request)?; assert_eq!(serialized["read_only_rootfs"], false); @@ -129,7 +137,7 @@ fn host_gateway_and_docker_facts_are_additive_and_default_safely() -> Result<(), #[test] fn version_and_capability_wire_snapshot_preserve_contract_compatibility() -> Result<(), serde_json::Error> { - assert_eq!(CONTRACT_VERSION, (2, 1)); + assert_eq!(CONTRACT_VERSION, (2, 3)); assert!(is_compatible(CONTRACT_VERSION)); assert!(!is_compatible((1, 21))); assert!(!is_compatible((1, 8))); @@ -144,7 +152,9 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility() assert!(!is_compatible((1, 22))); assert!(!is_compatible((3, 0))); assert!(!is_compatible((2, 0))); - assert!(is_compatible((2, 1))); + assert!(!is_compatible((2, 1))); + assert!(!is_compatible((2, 2))); + assert!(is_compatible((2, 3))); assert!(!supports_native_jail_resources((2, 0))); assert!(supports_native_jail_resources((2, 1))); assert!(supports_native_jail_resources((2, 2))); @@ -154,7 +164,7 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility() exec_backends: Vec::new(), spawn_backends: Vec::new(), }; - let snapshot = serde_json::json!({"contract_version":[2,1],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); + let snapshot = serde_json::json!({"contract_version":[2,3],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); assert_eq!(serde_json::to_value(&capabilities)?, snapshot); assert_eq!( serde_json::from_value::(snapshot)?, @@ -173,7 +183,7 @@ fn no_workspace_docker_operation_is_present_in_the_contract() { .count(), 1 ); - assert_eq!(CONTRACT_VERSION, (2, 1)); + assert_eq!(CONTRACT_VERSION, (2, 3)); } #[test] @@ -186,9 +196,10 @@ fn shell_analysis_wire_shape_contains_only_structural_facts() -> Result<(), serd #[test] fn contract_two_drops_command_classification_but_keeps_structural_shell_analysis() { - assert_eq!(CONTRACT_VERSION, (2, 1)); + assert_eq!(CONTRACT_VERSION, (2, 3)); assert!(!METHODS.contains(&"AnalyzeCommand")); assert!(METHODS.contains(&"AnalyzeShell")); + assert!(METHODS.contains(&"RunNativeShell")); } #[test] @@ -317,6 +328,39 @@ fn native_shell_wire_defaults_close_stdin_and_keep_a_bounded_runtime() Ok(()) } +#[test] +fn run_native_shell_result_preserves_finished_and_yielded_shapes() -> Result<(), serde_json::Error> +{ + let finished = RunNativeShellResult::Finished { + exit_code: 7, + stdout: b"out".to_vec(), + stderr: b"err".to_vec(), + timed_out: false, + }; + assert_eq!( + serde_json::from_value::(serde_json::to_value(&finished)?)?, + finished + ); + let running = RunNativeShellResult::Running { + resource: ResourceId("resource".into()), + process: ProcessRef { + resource: ResourceId("resource".into()), + process: ResourceId("process".into()), + }, + cursor: 4, + stdout: b"before".to_vec(), + stderr: Vec::new(), + }; + let value = serde_json::to_value(&running)?; + assert_eq!(value["state"], "running"); + assert_eq!( + serde_json::from_value::(value)?, + running + ); + assert_eq!(names::RUN_NATIVE_SHELL, "RunNativeShell"); + Ok(()) +} + #[test] fn named_members_resolve_to_the_method_catalogue_in_order() { use names::*; @@ -363,6 +407,7 @@ fn named_members_resolve_to_the_method_catalogue_in_order() { KILL_STDIO, STDIO_STATUS, START_JAILED_EXEC_WITH_RESOURCES, + RUN_NATIVE_SHELL, ]; assert_eq!(METHODS, &expected[..]); assert_eq!(DESCRIBE, "Describe"); diff --git a/crates/tinybox-bus/src/shell_execution.rs b/crates/tinybox-bus/src/shell_execution.rs index bd28c809..7fd9f046 100644 --- a/crates/tinybox-bus/src/shell_execution.rs +++ b/crates/tinybox-bus/src/shell_execution.rs @@ -1,5 +1,5 @@ //! Reserved native shell execution; authorization remains caller-owned. -use crate::{NativeJailConfig, ResourceId, SelectShellRequest}; +use crate::{CreateRequest, NativeJailConfig, ProcessRef, ResourceId, SelectShellRequest}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -34,6 +34,51 @@ pub struct ShellExecRequest { #[serde(default)] pub hide_window: bool, } + +/// Start and supervise a native shell through its first completion or yield. +/// Reservation identifiers inside `shell` are ignored and replaced by the +/// module. A yielded process remains module-owned and can be polled through +/// `ReadOutput` or stopped through `Cancel`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunNativeShellRequest { + /// Resource configuration; its reservation identifier is ignored. + pub create: CreateRequest, + /// Shell configuration; resource and process identifiers are ignored. + pub shell: ShellExecRequest, + /// Yield when still running after this duration, if shorter than timeout. + #[serde(default)] + pub yield_after_ms: Option, +} + +/// Result of a supervised shell call. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum RunNativeShellResult { + /// The process exited or timed out and cleanup was acknowledged. + Finished { + /// Process exit status, including nonzero results. + exit_code: i32, + /// Collected standard output bytes. + stdout: Vec, + /// Collected standard error bytes. + stderr: Vec, + /// Whether the shell exceeded its request timeout. + timed_out: bool, + }, + /// The process is still running and remains owned by the module. + Running { + /// Resource to close after process cleanup. + resource: ResourceId, + /// Process to poll or cancel. + process: ProcessRef, + /// Cursor for the next output read. + cursor: u64, + /// Standard output collected before yielding. + stdout: Vec, + /// Standard error collected before yielding. + stderr: Vec, + }, +} const fn default_timeout() -> u64 { 30_000 } diff --git a/crates/tinybox-bus/src/storage/box_v1.rs b/crates/tinybox-bus/src/storage/box_v1.rs index 66a95de1..f2340bf1 100644 --- a/crates/tinybox-bus/src/storage/box_v1.rs +++ b/crates/tinybox-bus/src/storage/box_v1.rs @@ -43,6 +43,8 @@ struct V1Spec { lifecycle: V1Lifecycle, network: V1Network, #[serde(default)] + hard_requirements: Vec, + #[serde(default)] ports: Vec, env: BTreeMap, } @@ -175,6 +177,7 @@ pub fn to_body(source: Value) -> Result { "resources": spec.resources, "lifecycle": lifecycle_body(&spec.lifecycle), "network": network_body(spec.network), + "hard_requirements": spec.hard_requirements, "ports": spec.ports, "env": spec.env, }, diff --git a/crates/tinybox-bus/src/storage/box_v1_tests.rs b/crates/tinybox-bus/src/storage/box_v1_tests.rs index 7a8bf5d0..b5e6599c 100644 --- a/crates/tinybox-bus/src/storage/box_v1_tests.rs +++ b/crates/tinybox-bus/src/storage/box_v1_tests.rs @@ -34,9 +34,21 @@ fn every_variant_takes_its_v2_tagged_form() { ); assert_eq!(body["spec"]["network"], json!({"kind": "open"})); assert_eq!(body["spec"]["ports"], json!([])); + assert_eq!(body["spec"]["hard_requirements"], json!([])); assert!(body.get("id").is_none(), "the id is the document id"); } +#[test] +fn hard_requirements_survive_the_v1_conversion() { + let mut old = v1(); + old["spec"]["hard_requirements"] = json!(["cpu", "filesystem"]); + let body = to_body(old).expect("decodes"); + assert_eq!( + body["spec"]["hard_requirements"], + json!(["cpu", "filesystem"]) + ); +} + #[test] fn a_persistent_box_without_autosnapshot_keeps_it_null() { let mut old = v1(); diff --git a/crates/tinybox-bus/src/storage/mod.rs b/crates/tinybox-bus/src/storage/mod.rs index 2a4f8345..f7ae4c75 100644 --- a/crates/tinybox-bus/src/storage/mod.rs +++ b/crates/tinybox-bus/src/storage/mod.rs @@ -4,7 +4,8 @@ //! legacy rows when their storage adapters open. The bus contract describes //! names and conversions only; it has no engine or migration runner. -use tinystoragedrivers_types::CollectionSpec; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; pub mod box_v1; pub mod jail_index; @@ -29,12 +30,27 @@ pub const V1_BOX_COLLECTION: &str = "tinybox_boxes"; /// The v1 name of [`JAIL_INDEX_COLLECTION`]. pub const V1_JAIL_INDEX_COLLECTION: &str = "tinybox_jail_index"; +/// A TinyBox-owned storage collection name, independent of a storage backend. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct CollectionDeclaration { + /// Stable collection name to provision in the host's bound storage scope. + pub name: String, +} + +impl CollectionDeclaration { + /// Declare a `TinyBox` collection by name. + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { name: name.into() } + } +} + /// The body schema version used by `TinyBox`'s current documents. pub const BODY_VERSION: u32 = 2; /// Collections owned by `TinyBox`, as pure declarations for the host to ensure. #[must_use] -pub fn collections() -> Vec { +pub fn collections() -> Vec { [ BOX_COLLECTION, JAIL_INDEX_COLLECTION, @@ -43,7 +59,7 @@ pub fn collections() -> Vec { CLI_IMPORTS_COLLECTION, ] .into_iter() - .map(CollectionSpec::new) + .map(CollectionDeclaration::new) .collect() } diff --git a/crates/tinybox-bus/src/storage/mod_tests.rs b/crates/tinybox-bus/src/storage/mod_tests.rs index 57469714..a1ed8e57 100644 --- a/crates/tinybox-bus/src/storage/mod_tests.rs +++ b/crates/tinybox-bus/src/storage/mod_tests.rs @@ -24,9 +24,26 @@ fn current_and_legacy_collection_names_are_distinct() { } #[test] -fn every_declared_collection_has_a_valid_v2_spec() { - for spec in collections() { - assert!(spec.validate().is_ok(), "invalid collection {}", spec.name); +fn storage_declarations_are_serializable_names_without_backend_policy() -> serde_json::Result<()> { + let declaration = CollectionDeclaration::new(BOX_COLLECTION); + + assert_eq!( + serde_json::to_value(&declaration)?, + serde_json::json!({ "name": BOX_COLLECTION }) + ); + assert_eq!( + serde_json::from_value::( + serde_json::json!({ "name": BOX_COLLECTION }) + )?, + declaration + ); + Ok(()) +} + +#[test] +fn every_declared_collection_has_a_nonempty_name() { + for declaration in collections() { + assert_ne!(declaration.name, ""); } assert_eq!(BODY_VERSION, 2); } diff --git a/crates/tinybox-core/Cargo.toml b/crates/tinybox-core/Cargo.toml index 490d593f..91155e85 100644 --- a/crates/tinybox-core/Cargo.toml +++ b/crates/tinybox-core/Cargo.toml @@ -24,6 +24,7 @@ tinybox-bus.workspace = true [dev-dependencies] tokio.workspace = true +serde_json.workspace = true [lints] workspace = true diff --git a/crates/tinybox-core/src/constraint/mod.rs b/crates/tinybox-core/src/constraint/mod.rs index 3b380419..c6391b3f 100644 --- a/crates/tinybox-core/src/constraint/mod.rs +++ b/crates/tinybox-core/src/constraint/mod.rs @@ -5,7 +5,8 @@ //! accepted as a hard requirement. Unknown backends default to unsupported. /// A security boundary requested before starting a workload. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] pub enum Constraint { /// Confine filesystem reads and writes to the granted paths. Filesystem, diff --git a/crates/tinybox-core/src/lib.rs b/crates/tinybox-core/src/lib.rs index c3203ed6..ad05768a 100644 --- a/crates/tinybox-core/src/lib.rs +++ b/crates/tinybox-core/src/lib.rs @@ -85,7 +85,8 @@ pub use identity::{BoxId, HostRef, ProcessId, SandboxRef, SnapshotId, TemplateNa pub use passthrough::PassthroughSandbox; pub use runtime::{ BoxInfo, BoxState, ExecOutput, ExecRequest, ExecutionObserver, Forward, ForwardGuard, Host, - HostExecutionLocation, OutputStream, Sandbox, WorkspaceFileReader, WorkspaceFileWriter, + HostExecutionLocation, OutputStream, Sandbox, TerminationCause, WorkspaceFileReader, + WorkspaceFileWriter, }; pub use spec::{ BoxSpec, EgressAllowlist, Lifecycle, NetworkPolicy, Placement, PortMapping, Resources, diff --git a/crates/tinybox-core/src/passthrough/mod.rs b/crates/tinybox-core/src/passthrough/mod.rs index f4023391..5f15fff5 100644 --- a/crates/tinybox-core/src/passthrough/mod.rs +++ b/crates/tinybox-core/src/passthrough/mod.rs @@ -161,6 +161,7 @@ impl Sandbox for PassthroughSandbox { async fn create(&self, spec: &BoxSpec) -> Result { spec.validate()?; + self.require_spec(spec)?; spec.network.refuse_allowlist(self.name())?; // Fail before recording anything, so a rejected source leaves no box // behind for a later command to trip over. diff --git a/crates/tinybox-core/src/passthrough/mod_tests.rs b/crates/tinybox-core/src/passthrough/mod_tests.rs index 0fadc105..a6af066c 100644 --- a/crates/tinybox-core/src/passthrough/mod_tests.rs +++ b/crates/tinybox-core/src/passthrough/mod_tests.rs @@ -95,6 +95,26 @@ async fn a_box_can_be_created_inspected_and_destroyed() -> Result<()> { Ok(()) } +#[tokio::test] +async fn passthrough_refuses_explicit_hard_requirements_before_storage() -> Result<()> { + use crate::{Constraint, Enforcement}; + + let store = Arc::new(MemoryStore::new()); + let sandbox = PassthroughSandbox::new(Arc::new(RecordingHost::default()), store.clone()); + let requested = spec()?.with_hard_requirements([Constraint::Filesystem]); + + assert!(matches!( + sandbox.create(&requested).await, + Err(Error::ConstraintNotEnforced { + constraint: Constraint::Filesystem, + enforcement: Enforcement::Unsupported, + .. + }) + )); + assert_eq!(store.list()?, []); + Ok(()) +} + #[tokio::test] async fn a_command_runs_in_the_workspace_directory() -> Result<()> { let (sandbox, host) = sandbox(); diff --git a/crates/tinybox-core/src/runtime/mod.rs b/crates/tinybox-core/src/runtime/mod.rs index 6fe8de95..6905d7e3 100644 --- a/crates/tinybox-core/src/runtime/mod.rs +++ b/crates/tinybox-core/src/runtime/mod.rs @@ -30,7 +30,7 @@ use crate::identity::{BoxId, ProcessId, SnapshotId}; use crate::spec::BoxSpec; mod output; -pub use output::{ExecutionObserver, OutputStream}; +pub use output::{ExecutionObserver, OutputStream, TerminationCause}; mod forward; #[cfg(test)] @@ -249,6 +249,11 @@ pub trait Sandbox: std::fmt::Debug + Send + Sync + 'static { self.constraint_support().plan_check(requested) } + /// Inspect only the hard requirements explicitly carried by a box spec. + fn plan_check_spec(&self, spec: &BoxSpec) -> crate::PlanCheck { + self.plan_check(&spec.hard_requirements) + } + /// Require fully implemented constraints before allocating or executing. /// /// This checks declarations only; callers must still propagate backend @@ -262,6 +267,15 @@ pub trait Sandbox: std::fmt::Debug + Send + Sync + 'static { self.constraint_support().require(self.name(), requested) } + /// Refuse a spec whose explicitly required constraints are not enforced. + /// + /// # Errors + /// Returns [`Error::ConstraintNotEnforced`] if any explicit requirement is + /// unsupported or only best effort. + fn require_spec(&self, spec: &BoxSpec) -> Result<()> { + self.require(&spec.hard_requirements) + } + /// Create a box from `spec` without starting any workload in it. /// /// # Errors diff --git a/crates/tinybox-core/src/runtime/output.rs b/crates/tinybox-core/src/runtime/output.rs index f3c4b532..33e079c1 100644 --- a/crates/tinybox-core/src/runtime/output.rs +++ b/crates/tinybox-core/src/runtime/output.rs @@ -1,10 +1,20 @@ -//! Provider-owned batch observation and cooperative execution cancellation. +//! Output streams and provider-proven resource termination causes. use crate::Result; use async_trait::async_trait; pub use tinybox_bus::OutputStream; +/// A resource-limit termination proven by provider-owned runtime evidence. +/// +/// Backends must not infer these causes from a signal or exit code alone. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum TerminationCause { + /// The Linux cgroup v2 CPU controller throttled work under its quota. + CpuQuota, +} + /// Receives bounded output batches inside the provider's execution runtime. /// /// Module adapters implement this with a bounded journal and a cancellation @@ -19,6 +29,9 @@ pub trait ExecutionObserver: std::fmt::Debug + Send + Sync + 'static { /// Returns an error if the bounded journal cannot accept this batch. fn output(&self, stream: OutputStream, bytes: &[u8]) -> Result<()>; + /// Record a provider-proven resource termination while execution is owned. + fn termination_cause(&self, _cause: TerminationCause) {} + /// Resolve when the owning module requests execution cancellation. /// /// The provider acknowledges cancellation only after killing and joining @@ -29,6 +42,18 @@ pub trait ExecutionObserver: std::fmt::Debug + Send + Sync + 'static { } } +#[cfg(test)] +#[derive(Debug)] +struct DefaultTerminationObserver; + +#[cfg(test)] +#[async_trait] +impl ExecutionObserver for DefaultTerminationObserver { + fn output(&self, _: OutputStream, _: &[u8]) -> Result<()> { + Ok(()) + } +} + #[cfg(test)] #[path = "output_tests.rs"] mod tests; diff --git a/crates/tinybox-core/src/runtime/output_tests.rs b/crates/tinybox-core/src/runtime/output_tests.rs index 6963b4aa..fd3c083a 100644 --- a/crates/tinybox-core/src/runtime/output_tests.rs +++ b/crates/tinybox-core/src/runtime/output_tests.rs @@ -59,3 +59,11 @@ fn a_default_observer_does_not_spuriously_request_cancellation() { let mut context = std::task::Context::from_waker(std::task::Waker::noop()); assert!(std::future::Future::poll(future.as_mut(), &mut context).is_pending()); } + +#[test] +fn a_default_observer_accepts_provider_termination_causes() { + let observer = DefaultTerminationObserver; + let observer: &dyn ExecutionObserver = &observer; + assert!(observer.output(OutputStream::Stdout, b"fixture").is_ok()); + observer.termination_cause(TerminationCause::CpuQuota); +} diff --git a/crates/tinybox-core/src/spec/mod.rs b/crates/tinybox-core/src/spec/mod.rs index 5404546d..2c096ae2 100644 --- a/crates/tinybox-core/src/spec/mod.rs +++ b/crates/tinybox-core/src/spec/mod.rs @@ -64,6 +64,13 @@ pub struct BoxSpec { pub source: WorkspaceSource, /// The limits applied to the workspace. pub resources: Resources, + /// Constraints the caller requires the selected sandbox to enforce fully. + /// + /// Resource values remain soft defaults unless their corresponding + /// constraint is listed here. Defaulted on read for stored specs created + /// before hard requirements were introduced. + #[serde(default)] + pub hard_requirements: Vec, /// How long the box lives and whether it snapshots as it goes. pub lifecycle: Lifecycle, /// What the workspace may reach over the network. @@ -97,6 +104,7 @@ impl BoxSpec { workspace: placement, source, resources: Resources::DEFAULT, + hard_requirements: Vec::new(), lifecycle: Lifecycle::default(), network: NetworkPolicy::default(), ports: BTreeSet::new(), @@ -131,6 +139,16 @@ impl BoxSpec { self } + /// Require the selected sandbox to fully enforce each listed constraint. + #[must_use] + pub fn with_hard_requirements( + mut self, + requirements: impl IntoIterator, + ) -> Self { + self.hard_requirements = requirements.into_iter().collect(); + self + } + /// Replace the lifecycle policy. #[must_use] pub fn with_lifecycle(mut self, lifecycle: Lifecycle) -> Self { diff --git a/crates/tinybox-core/src/spec/mod_tests.rs b/crates/tinybox-core/src/spec/mod_tests.rs index d7d1102a..d8a1a8eb 100644 --- a/crates/tinybox-core/src/spec/mod_tests.rs +++ b/crates/tinybox-core/src/spec/mod_tests.rs @@ -98,6 +98,20 @@ fn environment_order_does_not_change_spec_identity() -> Result<()> { Ok(()) } +#[test] +fn older_specs_default_hard_requirements_to_empty() +-> std::result::Result<(), Box> { + let mut value = serde_json::to_value(spec()?)?; + let Some(object) = value.as_object_mut() else { + return Err(std::io::Error::other("BoxSpec must serialize as an object").into()); + }; + object.remove("hard_requirements"); + + let restored: BoxSpec = serde_json::from_value(value)?; + assert_eq!(restored.hard_requirements, []); + Ok(()) +} + #[test] fn validate_rejects_each_zero_limit_in_turn() -> Result<()> { for (limit, resources) in [ diff --git a/crates/tinybox-core/tests/public_api.rs b/crates/tinybox-core/tests/public_api.rs index a2b0ad5b..0518f0eb 100644 --- a/crates/tinybox-core/tests/public_api.rs +++ b/crates/tinybox-core/tests/public_api.rs @@ -4,11 +4,29 @@ //! the export surface regresses even when the internals still compile. use tinybox_core::{ - BoxSpec, BoxState, Capability, Error, ExecOutput, ExecRequest, HostRef, IsolationLevel, - Lifecycle, NetworkPolicy, Placement, Resources, Result, SandboxCapabilities, SandboxRef, - SnapshotId, SnapshotSupport, WorkspaceSource, + BoxSpec, BoxState, Capability, Error, ExecOutput, ExecRequest, ExecutionObserver, HostRef, + IsolationLevel, Lifecycle, NetworkPolicy, OutputStream, Placement, Resources, Result, + SandboxCapabilities, SandboxRef, SnapshotId, SnapshotSupport, TerminationCause, + WorkspaceSource, }; +#[derive(Debug)] +struct Observer; + +#[async_trait::async_trait] +impl ExecutionObserver for Observer { + fn output(&self, _: OutputStream, _: &[u8]) -> Result<()> { + Ok(()) + } +} + +#[test] +fn public_observers_accept_proven_cpu_quota_causes() { + let observer = Observer; + let observer: &dyn ExecutionObserver = &observer; + observer.termination_cause(TerminationCause::CpuQuota); +} + /// Build a placement, propagating identifier validation failures to the test. fn placement(host: &str, sandbox: &str) -> Result { Ok(Placement::new( diff --git a/crates/tinybox-docker/src/sandbox/mod.rs b/crates/tinybox-docker/src/sandbox/mod.rs index 17b99ab7..956a8125 100644 --- a/crates/tinybox-docker/src/sandbox/mod.rs +++ b/crates/tinybox-docker/src/sandbox/mod.rs @@ -327,6 +327,10 @@ impl ExecutionObserver for DockerExecutionObserver { self.inner.output(stream, bytes) } + fn termination_cause(&self, cause: tinybox_core::TerminationCause) { + self.inner.termination_cause(cause); + } + async fn cancelled(&self) { self.inner.cancelled().await; match cleanup_remote(self.host.clone(), &self.namespace, &self.handle).await { @@ -775,6 +779,7 @@ impl Sandbox for DockerSandbox { async fn create(&self, spec: &BoxSpec) -> Result { spec.validate()?; + self.require_spec(spec)?; self.validate_network_policy(spec).await?; // The workspace is bind-mounted read-write, so it gets the one-shot // mount policy before any record or container exists. diff --git a/crates/tinybox-docker/src/sandbox/mod_tests.rs b/crates/tinybox-docker/src/sandbox/mod_tests.rs index 36b5129e..28e76de9 100644 --- a/crates/tinybox-docker/src/sandbox/mod_tests.rs +++ b/crates/tinybox-docker/src/sandbox/mod_tests.rs @@ -1014,6 +1014,7 @@ fn flag_value<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> { struct CapturingObserver { cancel: bool, batches: Mutex)>>, + causes: Mutex>, } impl CapturingObserver { @@ -1035,6 +1036,13 @@ impl tinybox_core::ExecutionObserver for CapturingObserver { Ok(()) } + fn termination_cause(&self, cause: tinybox_core::TerminationCause) { + self.causes + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(cause); + } + async fn cancelled(&self) { if !self.cancel { std::future::pending::<()>().await; @@ -1042,6 +1050,37 @@ impl tinybox_core::ExecutionObserver for CapturingObserver { } } +#[test] +fn docker_observer_forwards_provider_termination_causes() -> Result<()> { + let inner = Arc::new(CapturingObserver::default()); + let observer = super::DockerExecutionObserver { + inner: inner.clone(), + host: Arc::new(ScriptedHost::default()), + namespace: "fixture".into(), + handle: super::CleanupHandle { + id: BoxId::new("box-0")?, + token: "token".into(), + generation: 0, + launched: true, + acknowledged: Arc::default(), + unsupported: Arc::default(), + }, + observed_boxes: Arc::new(Mutex::new(std::collections::HashMap::new())), + pending: Arc::new(Mutex::new(std::collections::HashMap::new())), + control: Arc::new(super::ObservedControl::default()), + }; + + tinybox_core::ExecutionObserver::termination_cause( + &observer, + tinybox_core::TerminationCause::CpuQuota, + ); + assert_eq!( + *inner.causes.lock().unwrap_or_else(PoisonError::into_inner), + vec![tinybox_core::TerminationCause::CpuQuota] + ); + Ok(()) +} + #[test] fn it_declares_kernel_isolation_and_filesystem_snapshots() { let caps = DockerSandbox::declared_capabilities(); @@ -2501,3 +2540,20 @@ async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<( )); Ok(()) } + +#[tokio::test] +async fn an_unsupported_hard_requirement_is_refused_before_storage() -> Result<()> { + let (sandbox, _host, store) = sandbox(); + let requested = spec()?.with_hard_requirements([tinybox_core::Constraint::Disk]); + + assert!(matches!( + sandbox.create(&requested).await, + Err(Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Disk, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + }) + )); + assert_eq!(store.list()?, []); + Ok(()) +} diff --git a/crates/tinybox-drivers/src/box_store_tests.rs b/crates/tinybox-drivers/src/box_store_tests.rs index a7e52a2e..8fad433e 100644 --- a/crates/tinybox-drivers/src/box_store_tests.rs +++ b/crates/tinybox-drivers/src/box_store_tests.rs @@ -5,7 +5,7 @@ use tinybox_core::store::insert_new; use tinybox_core::{BoxSpec, HostRef, Placement, SandboxRef, WorkspaceSource}; use tinystoragedrivers_api::DocumentStore; use tinystoragedrivers_testkit::memory_storage; -use tinystoragedrivers_types::{Precondition, Scope, StorageError}; +use tinystoragedrivers_types::{CollectionSpec, Precondition, Scope, StorageError}; fn documents() -> Arc { memory_storage(&Scope::new("tinybox-tests").expect("scope")) @@ -18,8 +18,10 @@ fn test_store(docs: Arc) -> Result { let ensure_docs = Arc::clone(&docs); bridge .run(async move { - for spec in tinybox_bus::storage::collections() { - ensure_docs.ensure_collection(&spec).await?; + for declaration in tinybox_bus::storage::collections() { + ensure_docs + .ensure_collection(&CollectionSpec::new(declaration.name)) + .await?; } Ok::<_, StorageError>(()) }) diff --git a/crates/tinybox-drivers/src/index_store_tests.rs b/crates/tinybox-drivers/src/index_store_tests.rs index 9ec432f5..1276d45a 100644 --- a/crates/tinybox-drivers/src/index_store_tests.rs +++ b/crates/tinybox-drivers/src/index_store_tests.rs @@ -4,7 +4,7 @@ use tinybox_core::clock::FixedClock; use tinybox_jail::JailRegistry; use tinystoragedrivers_api::DocumentStore; use tinystoragedrivers_testkit::memory_storage; -use tinystoragedrivers_types::{Scope, StorageError, WriteOptions}; +use tinystoragedrivers_types::{CollectionSpec, Scope, StorageError, WriteOptions}; fn documents() -> Arc { memory_storage(&Scope::new("tinybox-index-tests").expect("scope")) @@ -17,8 +17,10 @@ fn test_store(docs: Arc, registry: &str) -> io::Result(()) }) diff --git a/crates/tinybox-drivers/src/migrations.rs b/crates/tinybox-drivers/src/migrations.rs index 5011a9b3..91fc42e3 100644 --- a/crates/tinybox-drivers/src/migrations.rs +++ b/crates/tinybox-drivers/src/migrations.rs @@ -36,8 +36,10 @@ struct MigrationReceipt { /// [`DocumentBoxStore::open_host_managed`]: crate::DocumentBoxStore::open_host_managed pub(crate) async fn declare(storage: &Storage) -> Result<()> { let documents = storage.documents(); - for spec in collections() { - documents.ensure_collection(&spec).await?; + for declaration in collections() { + documents + .ensure_collection(&CollectionSpec::new(declaration.name)) + .await?; } Ok(()) } diff --git a/crates/tinybox-drivers/src/record.rs b/crates/tinybox-drivers/src/record.rs index fa944ef1..2b467fc4 100644 --- a/crates/tinybox-drivers/src/record.rs +++ b/crates/tinybox-drivers/src/record.rs @@ -52,6 +52,8 @@ pub(crate) struct SpecRecord { resources: Resources, lifecycle: LifecycleRecord, network: NetworkRecord, + #[serde(default)] + hard_requirements: Vec, ports: BTreeSet, env: BTreeMap, } @@ -171,6 +173,7 @@ impl TryFrom<&BoxSpec> for SpecRecord { resources: spec.resources, lifecycle: LifecycleRecord::try_from(&spec.lifecycle)?, network: NetworkRecord::try_from(&spec.network)?, + hard_requirements: spec.hard_requirements.clone(), ports: spec.ports.clone(), env: spec.env.clone(), }) @@ -184,6 +187,7 @@ impl SpecRecord { spec.resources = self.resources; spec.lifecycle = self.lifecycle.into(); spec.network = self.network.into(); + spec.hard_requirements = self.hard_requirements; spec.ports = self.ports; spec.env = self.env; spec diff --git a/crates/tinybox-drivers/src/record_tests.rs b/crates/tinybox-drivers/src/record_tests.rs index 6d334fc2..c752420a 100644 --- a/crates/tinybox-drivers/src/record_tests.rs +++ b/crates/tinybox-drivers/src/record_tests.rs @@ -58,6 +58,7 @@ fn the_body_is_snake_case_with_millisecond_times_and_no_id() -> Result<()> { }, "lifecycle": {"kind": "persistent", "autosnapshot_ms": 60_000}, "network": {"kind": "denied"}, + "hard_requirements": [], "ports": [], "env": {"KEY": "value"}, }, @@ -136,6 +137,30 @@ fn an_unknown_creation_time_stays_unknown() -> Result<()> { Ok(()) } +#[test] +fn older_stored_records_default_hard_requirements_to_empty() -> Result<()> { + let info = info_with(base_spec(WorkspaceSource::OciImage("a".into()))?)?; + let mut value = + serde_json::to_value(BoxRecord::try_from(&info)?).map_err(|e| Error::Store { + operation: "encode", + message: e.to_string(), + })?; + value["spec"] + .as_object_mut() + .ok_or_else(|| Error::Store { + operation: "decode", + message: "spec record must be an object".to_owned(), + })? + .remove("hard_requirements"); + + let stored: BoxRecord = serde_json::from_value(value).map_err(|e| Error::Store { + operation: "decode", + message: e.to_string(), + })?; + assert_eq!(stored.into_info(info.id).spec.hard_requirements, []); + Ok(()) +} + #[test] fn a_creation_time_keeps_millisecond_precision() -> Result<()> { let at = UNIX_EPOCH + Duration::from_millis(42_007); @@ -157,6 +182,7 @@ fn the_contracts_v1_migration_matches_the_record_encoding() { ttl: Duration::from_secs(60), }; spec.network = NetworkPolicy::Allowlist(EgressAllowlist::new(["a.test"], vec![443])); + spec.hard_requirements = vec![tinybox_core::Constraint::Cpu]; spec.ports.insert(PortMapping { guest: 80, host: None, diff --git a/crates/tinybox-host/Cargo.toml b/crates/tinybox-host/Cargo.toml index a64ec104..4a65d08c 100644 --- a/crates/tinybox-host/Cargo.toml +++ b/crates/tinybox-host/Cargo.toml @@ -18,6 +18,9 @@ tinybox-core.workspace = true tinybox-bus.workspace = true tinybox-jail.workspace = true async-trait.workspace = true +# The host supervises async native-shell output and bounded poll delays. +futures-util = { version = "0.3", features = ["async-await"] } +futures-timer = "3" # `Dir` keeps workspace file handles beneath the approved root without # resolving untrusted paths through ambient filesystem APIs. cap-std = "4" @@ -36,6 +39,8 @@ command-group = { version = "5", features = ["with-tokio"] } [dev-dependencies] # Command tests run against real temporary directories rather than the CWD. tempfile = "3" +# Drives the runtime-neutral native shell orchestration tests without a runtime. +futures-executor = "0.3" [lints] workspace = true diff --git a/crates/tinybox-host/src/files_tests.rs b/crates/tinybox-host/src/files_tests.rs index 85f7c322..6deed474 100644 --- a/crates/tinybox-host/src/files_tests.rs +++ b/crates/tinybox-host/src/files_tests.rs @@ -1,7 +1,9 @@ //! Tests for confined workspace file handles. use super::{LocalFileWriter, begin_writer, open_reader, open_root}; -use std::io::{ErrorKind, Write}; +#[cfg(unix)] +use std::io::ErrorKind; +use std::io::Write; use std::path::{Path, PathBuf}; use tinybox_core::{Error, Result, WorkspaceFileWriter}; diff --git a/crates/tinybox-host/src/lib.rs b/crates/tinybox-host/src/lib.rs index 7fa6699b..0692f733 100644 --- a/crates/tinybox-host/src/lib.rs +++ b/crates/tinybox-host/src/lib.rs @@ -43,6 +43,7 @@ mod files; mod limited; mod local; +mod native_shell; /// Local reach with an enforced combined output budget and supervised cleanup. pub use limited::{JailLocalHost, LimitedLocalHost, ManagedProcess}; @@ -51,3 +52,9 @@ pub use local::{LocalHost, NAME as LOCAL}; /// Platform shell discovery and command construction owned by the local host. pub use local::shell::{build_shell_command, select_shell}; + +/// Runtime-neutral orchestration for supervised native-shell execution. +pub use native_shell::{ + NativeShellClient, NativeShellError, NativeShellOutput, NativeShellRunOutcome, + NativeShellRunRequest, NativeShellRunning, run_native_shell, +}; diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index c0286493..c29cd944 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -13,14 +13,154 @@ use tokio::io::AsyncWriteExt; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::sync::{oneshot, watch}; +#[cfg(target_os = "linux")] +const CPU_EVENT_FILE_ENV: &str = "TINYBOX_INTERNAL_CPU_EVENT_FILE"; +#[cfg(target_os = "linux")] +static CPU_EVENT_FILE_SEQUENCE: AtomicUsize = AtomicUsize::new(1); + +#[cfg(target_os = "linux")] +struct CpuEventReport(std::path::PathBuf); + +#[cfg(target_os = "linux")] +impl CpuEventReport { + fn prepare( + command: &std::process::Command, + env: &mut BTreeMap, + ) -> Option { + let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")?; + Self::prepare_in(command, env, std::path::Path::new(&runtime_dir)) + } + + fn prepare_in( + command: &std::process::Command, + env: &mut BTreeMap, + runtime_dir: &std::path::Path, + ) -> Option { + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + + let runtime_dir = runtime_dir.canonicalize().ok()?; + if !runtime_dir.starts_with("/run/") { + return None; + } + let args: Vec<_> = command.get_args().filter_map(|arg| arg.to_str()).collect(); + let has_scope = args.iter().any(|arg| { + arg.strip_prefix("--unit=") + .and_then(|unit| std::path::Path::new(unit).extension()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("scope")) + && arg.starts_with("--unit=tinybox-native-") + }); + let has_cpu_limit = args + .iter() + .any(|arg| arg.starts_with("--property=CPUQuota=")) + || args + .windows(2) + .any(|pair| pair[0] == "--property" && pair[1].starts_with("CPUQuota=")); + let masks_runtime = args.windows(2).any(|pair| pair == ["--tmpfs", "/run"]); + let masks_cgroup = args.windows(2).any(|pair| pair == ["--tmpfs", "/sys"]); + if !has_scope || !has_cpu_limit || !masks_runtime || !masks_cgroup { + return None; + } + let metadata = std::fs::metadata(&runtime_dir).ok()?; + let uid = std::fs::metadata("/proc/self").ok()?.uid(); + if !metadata.is_dir() || metadata.uid() != uid || metadata.mode() & 0o077 != 0 { + return None; + } + let sequence = CPU_EVENT_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = runtime_dir.join(format!( + "tinybox-cpu-event-{}-{sequence}", + std::process::id() + )); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .ok()?; + if std::io::Write::write_all(&mut file, b"nr_throttled 0\n").is_err() { + let _ = std::fs::remove_file(&path); + return None; + } + env.insert( + CPU_EVENT_FILE_ENV.into(), + path.to_string_lossy().into_owned(), + ); + Some(Self(path)) + } + + fn throttling_observed(&self) -> bool { + std::fs::read_to_string(&self.0) + .ok() + .and_then(|contents| parse_cpu_throttled_counter(&contents)) + .is_some_and(|count| count > 0) + } +} + +#[cfg(target_os = "linux")] +impl Drop for CpuEventReport { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +#[cfg(target_os = "linux")] +fn parse_cpu_throttled_counter(stats: &str) -> Option { + let mut counter = None; + for line in stats.lines() { + if let Some(value) = line.strip_prefix("nr_throttled ") { + if counter.is_some() { + return None; + } + counter = Some(value.parse().ok()?); + } + } + counter +} + +#[cfg(target_os = "linux")] +fn prepare_cpu_event_report( + command: &std::process::Command, + env: &mut BTreeMap, + observer_attached: bool, +) -> Option { + observer_attached + .then(|| CpuEventReport::prepare(command, env)) + .flatten() +} + +#[cfg(target_os = "linux")] +fn report_cpu_termination( + report: Option<&CpuEventReport>, + observer: Option<&Arc>, +) { + if report.is_some_and(CpuEventReport::throttling_observed) + && let Some(observer) = observer + { + observer.termination_cause(tinybox_core::TerminationCause::CpuQuota); + } +} + +fn configure_jail_command(command: &mut std::process::Command, env: &BTreeMap) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + command + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + command.env_clear().envs(env); +} + #[cfg(unix)] struct AsyncPipe(tokio::io::unix::AsyncFd); -// Native jail supervision is Unix-only today; retain a compile-time adapter for -// other targets so this module stays buildable if that capability is added. +// The Windows backend also exposes owned captured pipes; retain the portable +// Tokio file adapter for that path. #[cfg(not(unix))] type AsyncPipe = tokio::fs::File; +// Keep one fallible call shape across platforms; only Unix pipe registration can fail. +#[cfg_attr(not(unix), allow(clippy::unnecessary_wraps))] fn async_pipe(file: std::fs::File) -> Result { #[cfg(unix)] { @@ -196,19 +336,19 @@ impl std::fmt::Debug for JailLocalHost { impl JailLocalHost { /// Use the detected backend without weakening unavailable confinement. /// # Errors - /// Rejects unavailable backends and platforms without supervised standard children. + /// Rejects unavailable backends without owned-child supervision. pub fn new(jail: Jail, limit: usize) -> std::io::Result { - Self::with_backend(jail, tinybox_jail::default_backend(), limit) + Self::with_backend(jail, tinybox_jail::default_owned_backend(), limit) } /// Use an explicitly selected provider; hosts apply policy to its enforcement facts. /// # Errors - /// Rejects unavailable providers and unsupported native ownership platforms. + /// Rejects providers that cannot return an owned child. pub fn with_backend( jail: Jail, backend: Arc, limit: usize, ) -> std::io::Result { - if !cfg!(unix) || !backend.is_available() { + if !backend.is_owned_available() { return Err(std::io::Error::new( std::io::ErrorKind::Unsupported, "native jail supervision is unavailable", @@ -320,26 +460,46 @@ impl JailLocalHost { let mut command = crate::LocalHost::command(request)?.into_std(); // The host explicitly supplies the approved environment for jailed code. command.env_clear().envs(&request.env); - self.collect_command(command, request.stdin.clone(), observer) - .await + self.collect_command( + command, + request.stdin.clone(), + observer, + request.env.clone(), + ) + .await } - /// Collect a prepared shell inside this host's explicit native jail. - /// The caller clears and supplies the approved environment before invocation. + /// Collect a prepared shell inside this host's explicit native jail with an + /// empty environment; [`Self::run_command_observed_with_env`] passes one. /// # Errors /// Reports unavailable spawn, output bounds and retained kill/reap failures. pub async fn run_command_observed( + &self, + command: std::process::Command, + payload: Option>, + observer: Arc, + ) -> Result { + self.run_command_observed_with_env(command, payload, observer, BTreeMap::new()) + .await + } + + /// Collect a prepared command with the exact environment approved by its caller. + /// # Errors + /// Reports unavailable spawn, output bounds and retained kill/reap failures. + pub async fn run_command_observed_with_env( &self, mut command: std::process::Command, payload: Option>, observer: Arc, + env: BTreeMap, ) -> Result { command.stdin(if payload.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() }); - self.collect_command(command, payload, Some(observer)).await + self.collect_command(command, payload, Some(observer), env) + .await } async fn collect_command( @@ -347,15 +507,13 @@ impl JailLocalHost { mut command: std::process::Command, payload: Option>, observer: Option>, + mut env: BTreeMap, ) -> Result { - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - command.process_group(0); - } - command - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); + #[cfg(target_os = "linux")] + let cpu_event_report = prepare_cpu_event_report(&command, &mut env, observer.is_some()); + #[cfg(target_os = "linux")] + let event_observer = observer.clone(); + configure_jail_command(&mut command, &env); let jail = self.jail.clone(); let backend = self.backend.clone(); let state = self.state.clone(); @@ -370,8 +528,14 @@ impl JailLocalHost { if !matches!(cancelled.try_recv(), Err(oneshot::error::TryRecvError::Empty)) { return Err(failure("start native jail", "execution cancelled".into())); } + let approved_env = env.clone(); let child = tokio::task::spawn_blocking(move || { - tinybox_jail::spawn_owned_captured_with(backend.as_ref(), &jail, command) + tinybox_jail::spawn_owned_captured_with_backend_and_env( + backend.as_ref(), + &jail, + command, + &approved_env, + ) }) .await .map_err(|error| failure("start native jail", error.to_string()))? @@ -433,6 +597,9 @@ impl JailLocalHost { .await .map_err(|error| failure("collect native jail", error.to_string())); drop(cancel); + // A failed collection (output limit, cancellation) may still have been throttled. + #[cfg(target_os = "linux")] + report_cpu_termination(cpu_event_report.as_ref(), event_observer.as_ref()); result? } } @@ -689,3 +856,7 @@ where #[cfg(all(test, unix))] #[path = "jailed_tests.rs"] mod tests; + +#[cfg(all(test, target_os = "windows"))] +#[path = "jailed_windows_tests.rs"] +mod windows_tests; diff --git a/crates/tinybox-host/src/limited/jailed_tests.rs b/crates/tinybox-host/src/limited/jailed_tests.rs index 4e48655c..621d4376 100644 --- a/crates/tinybox-host/src/limited/jailed_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_tests.rs @@ -1,5 +1,91 @@ //! Native jail ownership regressions; providers are mocked without external services. use super::*; + +#[cfg(target_os = "linux")] +#[test] +fn cpu_throttling_counter_requires_one_well_formed_nr_throttled_value() { + assert_eq!( + parse_cpu_throttled_counter("usage_usec 12\nnr_throttled 0\n"), + Some(0) + ); + assert_eq!(parse_cpu_throttled_counter("nr_throttled 3\n"), Some(3)); + assert_eq!(parse_cpu_throttled_counter("nr_throttled nope\n"), None); + assert_eq!( + parse_cpu_throttled_counter("nr_throttled 1\nnr_throttled 2\n"), + None + ); + assert_eq!(parse_cpu_throttled_counter("usage_usec 12\n"), None); +} + +#[cfg(target_os = "linux")] +#[test] +fn cpu_event_report_rejects_custom_paths_and_accepts_masked_run_paths() +-> Result<(), Box> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let custom_runtime = tempfile::tempdir()?; + std::fs::set_permissions( + custom_runtime.path(), + std::fs::Permissions::from_mode(0o700), + )?; + let mut command = std::process::Command::new("systemd-run"); + command.args([ + "--scope", + "--unit=tinybox-native-fixture.scope", + "--property=CPUQuota=10%", + "--tmpfs", + "/run", + "--tmpfs", + "/sys", + "--clearenv", + ]); + let mut env = BTreeMap::new(); + assert!(CpuEventReport::prepare_in(&command, &mut env, custom_runtime.path()).is_none()); + assert!( + env.is_empty(), + "unmasked runtime paths must not be handed off" + ); + + let Some(runtime_root) = std::env::var_os("XDG_RUNTIME_DIR").map(std::path::PathBuf::from) + else { + return Ok(()); + }; + let Ok(metadata) = std::fs::metadata(&runtime_root) else { + return Ok(()); + }; + if !runtime_root.starts_with("/run/") + || metadata.uid() != std::fs::metadata("/proc/self")?.uid() + || metadata.mode() & 0o077 != 0 + { + return Ok(()); + } + let runtime = tempfile::Builder::new() + .prefix("tinybox-test-") + .tempdir_in(runtime_root)?; + std::fs::set_permissions(runtime.path(), std::fs::Permissions::from_mode(0o700))?; + let report = CpuEventReport::prepare_in(&command, &mut env, runtime.path()) + .ok_or("masked /run runtime path did not get a report")?; + let report_path = env + .get(CPU_EVENT_FILE_ENV) + .ok_or("report path was not passed to the trusted wrapper")?; + assert_eq!(std::fs::read_to_string(report_path)?, "nr_throttled 0\n"); + drop(report); + assert!(!std::path::Path::new(report_path).exists()); + env.clear(); + + let mut unmetered = std::process::Command::new("systemd-run"); + unmetered.args([ + "--scope", + "--unit=tinybox-native-fixture.scope", + "--tmpfs", + "/run", + "--tmpfs", + "/sys", + ]); + assert!(CpuEventReport::prepare_in(&unmetered, &mut env, custom_runtime.path()).is_none()); + assert!(env.is_empty()); + Ok(()) +} use std::result::Result; use std::sync::Arc; use tinybox_core::{ExecRequest, Host}; @@ -238,6 +324,37 @@ fn unavailable_jail_provider_refuses_instead_of_selecting_another_backend() Ok(()) } +#[cfg(target_os = "windows")] +#[tokio::test] +async fn detected_owned_backend_runs_with_only_the_approved_environment() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let host = JailLocalHost::new( + tinybox_jail::Jail::new(root.path(), "host-owned-appcontainer"), + 1024, + )?; + assert!(format!("{host:?}").contains("appcontainer")); + + let parent_path = std::env::var("PATH").ok(); + let mut request = ExecRequest::new(["cmd.exe", "/C", "echo %TINYBOX_APPROVED_ENV%|%PATH%"]); + request.cwd = Some(root.path().to_owned()); + request.env.insert( + "TINYBOX_APPROVED_ENV".to_owned(), + "approved-marker".to_owned(), + ); + let output = host.run(&request).await?; + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("approved-marker")); + if let Some(parent_path) = parent_path { + assert!( + !stdout.contains(&parent_path), + "parent PATH leaked into jailed process" + ); + } + host.drain_checked().await?; + Ok(()) +} + #[cfg(target_os = "linux")] #[tokio::test] async fn detected_landlock_collection_enforces_writable_root() diff --git a/crates/tinybox-host/src/limited/jailed_windows_tests.rs b/crates/tinybox-host/src/limited/jailed_windows_tests.rs new file mode 100644 index 00000000..daa6afd6 --- /dev/null +++ b/crates/tinybox-host/src/limited/jailed_windows_tests.rs @@ -0,0 +1,47 @@ +//! Windows integration coverage for the backend-owned `AppContainer` process path. + +use super::*; +use tinybox_core::ExecRequest; + +#[tokio::test] +async fn detected_owned_backend_runs_with_only_the_approved_environment() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let host = JailLocalHost::new( + tinybox_jail::Jail::new(root.path(), "host-owned-appcontainer"), + 1024, + )?; + assert!(format!("{host:?}").contains("appcontainer")); + + let parent_path = std::env::var("PATH").ok(); + let system_root = std::env::var_os("SystemRoot").ok_or("missing SystemRoot")?; + let cmd = std::path::PathBuf::from(system_root) + .join("System32") + .join("cmd.exe"); + let mut request = ExecRequest::new([ + cmd.to_string_lossy().as_ref(), + "/C", + "echo %TINYBOX_APPROVED_ENV% & echo %PATH%", + ]); + request.cwd = Some(root.path().to_owned()); + request.env.insert( + "TINYBOX_APPROVED_ENV".to_owned(), + "approved-marker".to_owned(), + ); + let output = host.run(&request).await?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("approved-marker"), + "stdout={stdout:?}, stderr={stderr:?}, exit_code={}", + output.exit_code + ); + if let Some(parent_path) = parent_path { + assert!( + !stdout.contains(&parent_path), + "parent PATH leaked into jailed process" + ); + } + host.drain_checked().await?; + Ok(()) +} diff --git a/crates/tinybox-host/src/native_shell.rs b/crates/tinybox-host/src/native_shell.rs new file mode 100644 index 00000000..e1f783f0 --- /dev/null +++ b/crates/tinybox-host/src/native_shell.rs @@ -0,0 +1,462 @@ +//! Runtime-neutral orchestration for one supervised native shell execution. + +use futures_util::{FutureExt, pin_mut, select}; +use std::{ + future::Future, + pin::Pin, + time::{Duration, Instant}, +}; + +use tinybox_bus::{ + CreateRequest, ExecutionFailure, ExecutionState, OutputBatch, OutputChunk, OutputStream, + ProcessRef, ReserveRequest, ResourceId, ShellExecRequest, +}; + +/// Transport operations required to supervise a native shell. +/// +/// Adapters implement these methods with their own bus connection and runtime. +pub trait NativeShellClient: Sync { + /// Transport error returned by one bus operation. + type Error; + + /// Reserve a resource or process identifier. + fn reserve( + &self, + request: ReserveRequest, + ) -> impl Future> + Send; + /// Create the resource with the module-issued reservation. + fn create( + &self, + request: CreateRequest, + ) -> impl Future> + Send; + /// Start the requested native shell and return its owned process reference. + fn start_native_shell_exec( + &self, + request: ShellExecRequest, + ) -> impl Future> + Send; + /// Read output starting at the supplied sequence cursor. + fn read_output( + &self, + process: ProcessRef, + cursor: u64, + ) -> impl Future> + Send; + /// Cancel and reap a running native process. + fn cancel(&self, process: ProcessRef) -> impl Future> + Send; + /// Release retained output after the process reaches a terminal state. + fn release_output( + &self, + process: ProcessRef, + ) -> impl Future> + Send; + /// Close the resource after process cleanup. + fn close(&self, resource: ResourceId) -> impl Future> + Send; +} + +/// Inputs for resource creation and native shell execution. +/// +/// The `resource` and process reservation fields in `create` and `shell` are +/// replaced by identifiers reserved during the run. +#[derive(Debug, Clone)] +pub struct NativeShellRunRequest { + /// Resource configuration; its reservation identifier is ignored. + pub create: CreateRequest, + /// Shell settings; its resource and process identifiers are ignored. + pub shell: ShellExecRequest, + /// Return a running process after this duration if it is shorter than the timeout. + pub yield_after: Option, +} + +/// Collected native shell output after the module acknowledges process cleanup. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeShellOutput { + /// Process exit code, including nonzero exits. + pub exit_code: i32, + /// Complete stdout bytes. + pub stdout: Vec, + /// Complete stderr bytes. + pub stderr: Vec, + /// Whether the shell exceeded its request timeout. + pub timed_out: bool, +} + +/// Outcome of running a shell through `TinyBox`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NativeShellRunOutcome { + /// The process exited or timed out and its output was released. + Finished(NativeShellOutput), + /// The process is still running; the caller now owns its cleanup lifecycle. + Running(NativeShellRunning), +} + +/// A running process handed back at the configured yield point. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeShellRunning { + /// Resource that must be closed after the process is finished. + pub resource: ResourceId, + /// Owned process reference for polling, cancellation, or release. + pub process: ProcessRef, + /// Cursor for the next output read. + pub cursor: u64, + /// Stdout collected before yielding. + pub stdout: Vec, + /// Stderr collected before yielding. + pub stderr: Vec, +} + +/// Failure during one phase of native shell orchestration. +#[derive(Debug)] +pub enum NativeShellError { + /// A bus operation failed. + Transport { + /// Human-readable operation that failed. + operation: &'static str, + /// Underlying adapter error. + source: E, + }, + /// The module reported a terminal failed state. + Execution(ExecutionFailure), + /// An output batch did not continue exactly at the cursor: a replayed or + /// skipped chunk, or a next sequence that disagrees with its chunks. + InvalidOutputCursor, + /// Cancellation failed, so native cleanup is not acknowledged. + Cancellation(E), + /// Output release failed after the process had stopped. + OutputRelease(E), + /// Resource close failed after process cleanup. + ResourceClose(E), + /// Cleanup failed while another operation was already failing. + Cleanup { + /// Cleanup operation that failed. + operation: &'static str, + /// Cleanup adapter error. + source: E, + /// Earlier failure that led to cleanup. + during: Box, + }, +} + +/// Run one native shell and stream every output chunk to `on_chunk`. +/// +/// The caller must keep this future supervised while signaling `cancelled`; +/// dropping it cannot await native process cleanup. The helper reserves and +/// creates the resource, starts the process, polls replayable output, cancels +/// on timeout or caller cancellation, releases output, and closes the resource. +/// +/// # Errors +/// Returns the transport failure, module execution failure, or cleanup failure. +pub async fn run_native_shell( + client: &C, + request: NativeShellRunRequest, + cancelled: Fut, + mut on_chunk: F, +) -> Result> +where + C: NativeShellClient, + F: FnMut(&OutputChunk), + Fut: Future + Send, + F: Send, + C::Error: Send, +{ + let timeout_ms = request.shell.timeout_ms.max(1); + let yield_after = request + .yield_after + .filter(|after| *after < Duration::from_millis(timeout_ms)); + let yield_started = Instant::now(); + let resource = client + .reserve(ReserveRequest::Resource) + .await + .map_err(|source| NativeShellError::Transport { + operation: "reserve resource", + source, + })?; + let mut shell = request.shell; + shell.timeout_ms = timeout_ms; + let result = async { + let process = start_native_shell(client, resource.clone(), request.create, shell).await?; + collect_native_shell( + client, + process, + NativeShellCollectionConfig { + resource: resource.clone(), + timeout_ms, + yield_after, + yield_started, + }, + cancelled, + &mut on_chunk, + ) + .await + } + .await; + let result = match result { + Ok(NativeShellRunOutcome::Running(running)) => { + return Ok(NativeShellRunOutcome::Running(running)); + } + result => result, + }; + let close_result = client.close(resource).await; + match (result, close_result) { + (Ok(output), Ok(())) => Ok(output), + (Err(error), Ok(())) => Err(error), + (Ok(_), Err(source)) => Err(NativeShellError::ResourceClose(source)), + (Err(error), Err(source)) => Err(NativeShellError::Cleanup { + operation: "close resource", + source, + during: Box::new(error), + }), + } +} + +async fn start_native_shell( + client: &C, + resource: ResourceId, + mut create: CreateRequest, + mut shell: ShellExecRequest, +) -> Result> { + create.resource = resource.clone(); + client + .create(create) + .await + .map_err(|source| NativeShellError::Transport { + operation: "create resource", + source, + })?; + let process_id = client + .reserve(ReserveRequest::Process(resource.clone())) + .await + .map_err(|source| NativeShellError::Transport { + operation: "reserve process", + source, + })?; + shell.resource = resource; + shell.process = process_id; + client + .start_native_shell_exec(shell) + .await + .map_err(|source| NativeShellError::Transport { + operation: "start native shell", + source, + }) +} + +async fn collect_native_shell( + client: &C, + process: ProcessRef, + config: NativeShellCollectionConfig, + cancelled: Fut, + on_chunk: &mut F, +) -> Result> +where + C: NativeShellClient, + F: FnMut(&OutputChunk), + Fut: Future + Send, + F: Send, + C::Error: Send, +{ + let deadline = Instant::now() + Duration::from_millis(config.timeout_ms); + let mut capture = NativeShellCapture::default(); + let cancelled = cancelled.fuse(); + pin_mut!(cancelled); + let mut cancellation_requested = false; + let mut timed_out = false; + loop { + let poll = next_native_shell_batch( + client, + &process, + capture.cursor, + deadline, + cancellation_requested, + cancelled.as_mut(), + ) + .await?; + match poll { + NativeShellPoll::Cancelled => { + cancellation_requested = true; + } + NativeShellPoll::TimedOut => { + cancellation_requested = true; + timed_out = true; + } + NativeShellPoll::Batch(batch) => { + let has_chunks = !batch.chunks.is_empty(); + if !capture.append(&batch, on_chunk) { + let primary = NativeShellError::InvalidOutputCursor; + if let Err(source) = client.cancel(process.clone()).await { + return Err(NativeShellError::Cleanup { + operation: "cancel after invalid output cursor", + source, + during: Box::new(primary), + }); + } + if let Err(source) = client.release_output(process.clone()).await { + return Err(NativeShellError::Cleanup { + operation: "release output after invalid cursor", + source, + during: Box::new(primary), + }); + } + return Err(primary); + } + if has_chunks && !matches!(&batch.state, ExecutionState::Running) { + continue; + } + if let Some(output) = + finish_native_shell(client, &process, &mut capture, batch.state, timed_out) + .await? + { + return Ok(NativeShellRunOutcome::Finished(output)); + } + if !cancellation_requested + && config + .yield_after + .is_some_and(|after| config.yield_started.elapsed() >= after) + { + return Ok(NativeShellRunOutcome::Running(NativeShellRunning { + resource: config.resource, + process, + cursor: capture.cursor, + stdout: capture.stdout, + stderr: capture.stderr, + })); + } + } + } + } +} + +async fn finish_native_shell( + client: &C, + process: &ProcessRef, + capture: &mut NativeShellCapture, + state: ExecutionState, + timed_out: bool, +) -> Result, NativeShellError> { + let output = match state { + ExecutionState::Running => return Ok(None), + ExecutionState::Exited { exit_code } => capture.finish(exit_code, timed_out), + ExecutionState::Cancelled => capture.finish(-1, timed_out), + ExecutionState::Failed { + code: ExecutionFailure::TimedOut, + } => capture.finish(-1, true), + ExecutionState::Failed { code } => { + client + .release_output(process.clone()) + .await + .map_err(NativeShellError::OutputRelease)?; + return Err(NativeShellError::Execution(code)); + } + }; + client + .release_output(process.clone()) + .await + .map_err(NativeShellError::OutputRelease)?; + Ok(Some(output)) +} + +struct NativeShellCollectionConfig { + resource: ResourceId, + timeout_ms: u64, + yield_after: Option, + yield_started: Instant, +} + +#[derive(Default)] +struct NativeShellCapture { + cursor: u64, + stdout: Vec, + stderr: Vec, +} + +impl NativeShellCapture { + fn append(&mut self, batch: &OutputBatch, on_chunk: &mut F) -> bool + where + F: FnMut(&OutputChunk), + { + // Chunks must continue exactly at the cursor, with no replay or gap. + let contiguous = batch + .chunks + .iter() + .zip(self.cursor..) + .all(|(chunk, sequence)| chunk.sequence == sequence); + if !contiguous || batch.next_sequence != self.cursor + batch.chunks.len() as u64 { + return false; + } + for chunk in &batch.chunks { + on_chunk(chunk); + match chunk.stream { + OutputStream::Stdout => self.stdout.extend_from_slice(&chunk.bytes), + OutputStream::Stderr => self.stderr.extend_from_slice(&chunk.bytes), + } + } + self.cursor = batch.next_sequence; + true + } + + fn finish(&mut self, exit_code: i32, timed_out: bool) -> NativeShellOutput { + NativeShellOutput { + exit_code, + stdout: std::mem::take(&mut self.stdout), + stderr: std::mem::take(&mut self.stderr), + timed_out, + } + } +} + +enum NativeShellPoll { + Batch(OutputBatch), + Cancelled, + TimedOut, +} + +async fn next_native_shell_batch( + client: &C, + process: &ProcessRef, + cursor: u64, + deadline: Instant, + cancellation_requested: bool, + mut cancelled: Pin<&mut futures_util::future::Fuse>, +) -> Result> +where + C: NativeShellClient, + C::Error: Send, + Fut: Future + Send, +{ + let remaining = deadline.saturating_duration_since(Instant::now()); + let poll = client.read_output(process.clone(), cursor).fuse(); + let delay = futures_timer::Delay::new(if cancellation_requested { + Duration::from_millis(20) + } else { + remaining.min(Duration::from_millis(20)) + }) + .fuse(); + pin_mut!(poll, delay); + if cancellation_requested { + select! { + result = poll => result + .map(NativeShellPoll::Batch) + .map_err(|source| NativeShellError::Transport { operation: "read output", source }), + } + } else { + select! { + result = poll => match result { + Ok(batch) => Ok(NativeShellPoll::Batch(batch)), + Err(source) => { + client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; + client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; + Err(NativeShellError::Transport { operation: "read output", source }) + } + }, + () = cancelled => { + client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; + Ok(NativeShellPoll::Cancelled) + }, + () = delay => { + client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; + Ok(NativeShellPoll::TimedOut) + } + } + } +} + +#[cfg(test)] +#[path = "native_shell_tests.rs"] +mod tests; diff --git a/crates/tinybox-host/src/native_shell_tests.rs b/crates/tinybox-host/src/native_shell_tests.rs new file mode 100644 index 00000000..3ea14ce7 --- /dev/null +++ b/crates/tinybox-host/src/native_shell_tests.rs @@ -0,0 +1,587 @@ +//! Tests for runtime-neutral native shell orchestration. +use super::*; +use std::{collections::BTreeMap, sync::Mutex}; +use tinybox_bus::{ + ExecutionFailure, HostConfig, NetworkPolicy, OutputStream, ResourceLimits, SelectShellRequest, + Workspace, +}; + +struct MockClient { + calls: Mutex>, + reads: Mutex, + behavior: MockBehavior, +} + +#[derive(Clone, Copy)] +enum MockBehavior { + Normal, + FailCreate, + Cancelled, + ModuleTimeout, + Running, + FailResourceReserve, + FailProcessReserve, + FailStart, + FailRead, + FailCancel, + FailRelease, + FailClose, + FailedExecution, + InvalidCursor, + FailedExecutionCloseFail, + TimedOut, + InvalidCursorCancelFail, + InvalidCursorReleaseFail, + RunningStderr, + SkippedChunk, +} + +impl NativeShellClient for MockClient { + type Error = &'static str; + + async fn reserve(&self, request: ReserveRequest) -> Result { + std::future::ready(()).await; + self.calls.lock().map_err(|_| "poisoned")?.push("reserve"); + if matches!(self.behavior, MockBehavior::FailResourceReserve) + && matches!(request, ReserveRequest::Resource) + || matches!(self.behavior, MockBehavior::FailProcessReserve) + && matches!(request, ReserveRequest::Process(_)) + { + return Err("reserve failed"); + } + Ok(match request { + ReserveRequest::Resource => ResourceId("resource-1".into()), + ReserveRequest::Process(_) => ResourceId("process-1".into()), + _ => return Err("unexpected reservation"), + }) + } + + async fn create(&self, request: CreateRequest) -> Result<(), Self::Error> { + std::future::ready(()).await; + self.calls.lock().map_err(|_| "poisoned")?.push("create"); + assert_eq!(request.resource, ResourceId("resource-1".into())); + if matches!(self.behavior, MockBehavior::FailCreate) { + return Err("create failed"); + } + Ok(()) + } + + async fn start_native_shell_exec( + &self, + request: ShellExecRequest, + ) -> Result { + std::future::ready(()).await; + self.calls.lock().map_err(|_| "poisoned")?.push("start"); + assert_eq!(request.process, ResourceId("process-1".into())); + assert!(request.timeout_ms > 0, "the module refuses a zero timeout"); + if matches!(self.behavior, MockBehavior::FailStart) { + return Err("start failed"); + } + Ok(ProcessRef { + resource: request.resource, + process: request.process, + }) + } + + async fn read_output(&self, _: ProcessRef, cursor: u64) -> Result { + std::future::ready(()).await; + self.calls.lock().map_err(|_| "poisoned")?.push("read"); + let cancelled = self + .calls + .lock() + .map_err(|_| "poisoned")? + .contains(&"cancel"); + // A cancellation test must reach `cancel`, so its read never wins the race. + if matches!( + self.behavior, + MockBehavior::TimedOut | MockBehavior::FailCancel + ) && !cancelled + { + std::future::pending::<()>().await; + } + if matches!(self.behavior, MockBehavior::FailRead) { + return Err("read failed"); + } + let mut reads = self.reads.lock().map_err(|_| "poisoned")?; + *reads = reads.wrapping_add(1); + let state = if matches!( + self.behavior, + MockBehavior::Cancelled | MockBehavior::TimedOut + ) { + if cancelled { + ExecutionState::Cancelled + } else { + ExecutionState::Running + } + } else if matches!(self.behavior, MockBehavior::ModuleTimeout) { + ExecutionState::Failed { + code: ExecutionFailure::TimedOut, + } + } else if matches!(self.behavior, MockBehavior::RunningStderr) && cancelled { + ExecutionState::Cancelled + } else if matches!( + self.behavior, + MockBehavior::Running | MockBehavior::RunningStderr + ) { + ExecutionState::Running + } else if matches!( + self.behavior, + MockBehavior::FailedExecution | MockBehavior::FailedExecutionCloseFail + ) { + ExecutionState::Failed { + code: ExecutionFailure::OutputLimit, + } + } else { + ExecutionState::Exited { exit_code: 7 } + }; + Ok(OutputBatch { + chunks: if cursor == 0 { + vec![OutputChunk { + sequence: u64::from(matches!(self.behavior, MockBehavior::SkippedChunk)), + stream: if matches!( + self.behavior, + MockBehavior::RunningStderr + | MockBehavior::InvalidCursor + | MockBehavior::InvalidCursorCancelFail + | MockBehavior::InvalidCursorReleaseFail + ) { + OutputStream::Stderr + } else { + OutputStream::Stdout + }, + bytes: b"done".to_vec(), + }] + } else { + Vec::new() + }, + next_sequence: if matches!( + self.behavior, + MockBehavior::InvalidCursor + | MockBehavior::InvalidCursorCancelFail + | MockBehavior::InvalidCursorReleaseFail + ) { + 0 + } else if cursor == 0 { + 1 + } else { + cursor + }, + state, + }) + } + + async fn cancel(&self, _: ProcessRef) -> Result<(), Self::Error> { + std::future::ready(()).await; + self.calls.lock().map_err(|_| "poisoned")?.push("cancel"); + if matches!( + self.behavior, + MockBehavior::FailCancel | MockBehavior::InvalidCursorCancelFail + ) { + return Err("cancel failed"); + } + Ok(()) + } + + async fn release_output(&self, _: ProcessRef) -> Result<(), Self::Error> { + std::future::ready(()).await; + self.calls.lock().map_err(|_| "poisoned")?.push("release"); + if matches!( + self.behavior, + MockBehavior::FailRelease | MockBehavior::InvalidCursorReleaseFail + ) { + return Err("release failed"); + } + Ok(()) + } + + async fn close(&self, _: ResourceId) -> Result<(), Self::Error> { + std::future::ready(()).await; + self.calls.lock().map_err(|_| "poisoned")?.push("close"); + if matches!( + self.behavior, + MockBehavior::FailClose | MockBehavior::FailedExecutionCloseFail + ) { + return Err("close failed"); + } + Ok(()) + } +} + +#[test] +fn runs_and_cleans_up_a_shell_while_streaming_chunks() -> Result<(), &'static str> { + futures_executor::block_on(runs_and_cleans_up_a_shell()) +} + +async fn runs_and_cleans_up_a_shell() -> Result<(), &'static str> { + let client = MockClient { + calls: Mutex::new(Vec::new()), + reads: Mutex::new(0), + behavior: MockBehavior::Normal, + }; + let mut chunks = Vec::new(); + let output = run_native_shell(&client, request(), std::future::pending(), |chunk| { + chunks.push(chunk.clone()); + }) + .await + .map_err(|_| "run failed")?; + let NativeShellRunOutcome::Finished(output) = output else { + return Err("expected a finished outcome"); + }; + assert_eq!(output.exit_code, 7); + assert_eq!(output.stdout, b"done"); + assert!(!output.timed_out); + assert_eq!(chunks.len(), 1); + assert_eq!( + *client.calls.lock().map_err(|_| "poisoned")?, + [ + "reserve", "create", "reserve", "start", "read", "read", "release", "close" + ] + ); + Ok(()) +} + +#[test] +fn cancellation_reaps_the_process_before_releasing_and_closing() -> Result<(), &'static str> { + futures_executor::block_on(async { + let client = MockClient { + calls: Mutex::new(Vec::new()), + reads: Mutex::new(0), + behavior: MockBehavior::Cancelled, + }; + let output = run_native_shell(&client, request(), cancel_now(), ignore_chunk) + .await + .map_err(|_| "run failed")?; + let NativeShellRunOutcome::Finished(output) = output else { + return Err("expected a finished outcome"); + }; + assert_eq!(output.exit_code, -1); + assert!(!output.timed_out); + let calls = client.calls.lock().map_err(|_| "poisoned")?; + assert_eq!(&calls[..4], &["reserve", "create", "reserve", "start"]); + let cancel = calls + .iter() + .position(|call| *call == "cancel") + .ok_or("cancel missing")?; + let release = calls + .iter() + .position(|call| *call == "release") + .ok_or("release missing")?; + let close = calls + .iter() + .position(|call| *call == "close") + .ok_or("close missing")?; + assert!(cancel < release && release < close); + Ok(()) + }) +} + +#[test] +fn create_failure_still_releases_the_resource_reservation() -> Result<(), &'static str> { + futures_executor::block_on(async { + let client = MockClient { + calls: Mutex::new(Vec::new()), + reads: Mutex::new(0), + behavior: MockBehavior::FailCreate, + }; + let error = run_native_shell(&client, request(), never_cancel(), ignore_chunk) + .await + .err() + .ok_or("expected failure")?; + assert!(matches!( + error, + NativeShellError::Transport { + operation: "create resource", + .. + } + )); + assert_eq!( + *client.calls.lock().map_err(|_| "poisoned")?, + ["reserve", "create", "close"] + ); + Ok(()) + }) +} + +#[test] +fn module_timeout_returns_collected_output_as_a_result() -> Result<(), &'static str> { + futures_executor::block_on(async { + let client = MockClient { + calls: Mutex::new(Vec::new()), + reads: Mutex::new(0), + behavior: MockBehavior::ModuleTimeout, + }; + let outcome = run_native_shell(&client, request(), never_cancel(), ignore_chunk) + .await + .map_err(|_| "run failed")?; + let NativeShellRunOutcome::Finished(output) = outcome else { + return Err("expected a finished outcome"); + }; + assert!(output.timed_out); + assert_eq!(output.stdout, b"done"); + assert_eq!(output.exit_code, -1); + Ok(()) + }) +} + +#[test] +fn yield_preserves_process_resource_cursor_and_partial_output() -> Result<(), &'static str> { + futures_executor::block_on(async { + let client = MockClient { + calls: Mutex::new(Vec::new()), + reads: Mutex::new(0), + behavior: MockBehavior::Running, + }; + let mut request = request(); + request.yield_after = Some(std::time::Duration::ZERO); + let outcome = run_native_shell(&client, request, never_cancel(), ignore_chunk) + .await + .map_err(|_| "run failed")?; + let NativeShellRunOutcome::Running(running) = outcome else { + return Err("expected a running outcome"); + }; + assert_eq!(running.resource, ResourceId("resource-1".into())); + assert_eq!(running.process.process, ResourceId("process-1".into())); + assert_eq!(running.cursor, 1); + assert_eq!(running.stdout, b"done"); + assert_eq!( + *client.calls.lock().map_err(|_| "poisoned")?, + ["reserve", "create", "reserve", "start", "read"] + ); + Ok(()) + }) +} + +#[test] +fn reserve_start_and_read_failures_are_reported_and_cleaned_up() -> Result<(), &'static str> { + for (behavior, operation) in [ + (MockBehavior::FailResourceReserve, "reserve resource"), + (MockBehavior::FailProcessReserve, "reserve process"), + (MockBehavior::FailStart, "start native shell"), + (MockBehavior::FailRead, "read output"), + ] { + futures_executor::block_on(async { + let client = mock(behavior); + let error = run_native_shell(&client, request(), never_cancel(), ignore_chunk) + .await + .err() + .ok_or("operation should fail")?; + if operation == "read output" { + assert!(matches!( + error, + NativeShellError::Transport { + operation: "read output", + .. + } + )); + assert!( + client + .calls + .lock() + .map_err(|_| "poisoned")? + .contains(&"cancel") + ); + assert!( + client + .calls + .lock() + .map_err(|_| "poisoned")? + .contains(&"release") + ); + } else { + assert!( + matches!(error, NativeShellError::Transport { operation: actual, .. } if actual == operation) + ); + } + Ok::<(), &'static str>(()) + })?; + } + Ok(()) +} + +#[test] +fn terminal_execution_and_cleanup_failures_keep_the_primary_error() -> Result<(), &'static str> { + futures_executor::block_on(async { + let client = mock(MockBehavior::FailedExecution); + assert!(matches!( + run_native_shell(&client, request(), never_cancel(), ignore_chunk).await, + Err(NativeShellError::Execution(ExecutionFailure::OutputLimit)) + )); + Ok::<(), &'static str>(()) + })?; + futures_executor::block_on(async { + let client = mock(MockBehavior::FailClose); + assert!(matches!( + run_native_shell(&client, request(), never_cancel(), ignore_chunk).await, + Err(NativeShellError::ResourceClose("close failed")) + )); + Ok::<(), &'static str>(()) + })?; + futures_executor::block_on(async { + let client = mock(MockBehavior::FailedExecutionCloseFail); + let err = run_native_shell(&client, request(), never_cancel(), ignore_chunk) + .await + .err() + .ok_or("expected cleanup failure")?; + assert!(matches!( + err, + NativeShellError::Cleanup { + operation: "close resource", + .. + } + )); + Ok::<(), &'static str>(()) + })?; + Ok(()) +} + +#[test] +fn invalid_cursor_and_cancel_cleanup_errors_are_reported() { + futures_executor::block_on(async { + let client = mock(MockBehavior::InvalidCursor); + assert!(matches!( + run_native_shell(&client, request(), never_cancel(), ignore_chunk).await, + Err(NativeShellError::InvalidOutputCursor) + )); + }); + futures_executor::block_on(async { + let client = mock(MockBehavior::SkippedChunk); + assert!(matches!( + run_native_shell(&client, request(), never_cancel(), ignore_chunk).await, + Err(NativeShellError::InvalidOutputCursor) + )); + }); + futures_executor::block_on(async { + let client = mock(MockBehavior::FailCancel); + assert!(matches!( + run_native_shell(&client, request(), cancel_now(), ignore_chunk).await, + Err(NativeShellError::Cancellation("cancel failed")) + )); + }); + futures_executor::block_on(async { + let client = mock(MockBehavior::FailRelease); + assert!(matches!( + run_native_shell(&client, request(), never_cancel(), ignore_chunk).await, + Err(NativeShellError::OutputRelease("release failed")) + )); + }); + futures_executor::block_on(async { + let client = mock(MockBehavior::InvalidCursorCancelFail); + assert!(matches!( + run_native_shell(&client, request(), never_cancel(), ignore_chunk).await, + Err(NativeShellError::Cleanup { + operation: "cancel after invalid output cursor", + .. + }) + )); + }); + futures_executor::block_on(async { + let client = mock(MockBehavior::InvalidCursorReleaseFail); + assert!(matches!( + run_native_shell(&client, request(), never_cancel(), ignore_chunk).await, + Err(NativeShellError::Cleanup { + operation: "release output after invalid cursor", + .. + }) + )); + }); +} + +#[test] +fn timeout_cancels_and_returns_collected_output() -> Result<(), &'static str> { + // A zero timeout starts with the same one-millisecond floor it collects with. + for timeout_ms in [0, 1] { + futures_executor::block_on(async { + let client = mock(MockBehavior::TimedOut); + let mut req = request(); + req.shell.timeout_ms = timeout_ms; + let outcome = run_native_shell(&client, req, never_cancel(), ignore_chunk) + .await + .map_err(|_| "run failed")?; + let NativeShellRunOutcome::Finished(output) = outcome else { + return Err("expected completion"); + }; + assert!(output.timed_out); + assert_eq!(output.exit_code, -1); + assert!( + client + .calls + .lock() + .map_err(|_| "poisoned")? + .contains(&"cancel") + ); + Ok(()) + })?; + } + Ok(()) +} + +#[test] +fn running_poll_without_yield_accumulates_stderr_until_timeout() -> Result<(), &'static str> { + futures_executor::block_on(async { + let client = mock(MockBehavior::RunningStderr); + let mut req = request(); + req.shell.timeout_ms = 1; + req.yield_after = Some(std::time::Duration::from_secs(5)); + let outcome = run_native_shell(&client, req, never_cancel(), ignore_chunk) + .await + .map_err(|_| "run failed")?; + let NativeShellRunOutcome::Finished(output) = outcome else { + return Err("expected completion"); + }; + assert!(output.timed_out); + assert_eq!(output.stderr, b"done"); + assert_eq!(output.stdout.len(), 0); + Ok(()) + }) +} + +fn mock(behavior: MockBehavior) -> MockClient { + MockClient { + calls: Mutex::new(Vec::new()), + reads: Mutex::new(0), + behavior, + } +} + +fn never_cancel() -> impl std::future::Future { + std::future::pending::<()>() +} + +fn cancel_now() -> impl std::future::Future { + std::future::ready(()) +} + +fn ignore_chunk(_: &OutputChunk) {} + +fn request() -> NativeShellRunRequest { + NativeShellRunRequest { + create: CreateRequest { + resource: ResourceId("placeholder-resource".into()), + backend: "passthrough".into(), + host: HostConfig::Local, + workspace: Workspace::Directory("/workspace".into()), + network: NetworkPolicy::Open, + resources: ResourceLimits::default(), + ports: Vec::new(), + env: BTreeMap::new(), + }, + shell: ShellExecRequest { + process: ResourceId("placeholder-process".into()), + resource: ResourceId("placeholder-resource".into()), + shell: SelectShellRequest { + command: "echo done".into(), + preserve_pipeline_status: false, + runtime_path: None, + python_utf8: false, + }, + cwd: None, + extra_env: BTreeMap::new(), + state_dir: "/state".into(), + timeout_ms: 1_000, + stdin: None, + jail: None, + hide_window: false, + }, + yield_after: None, + } +} diff --git a/crates/tinybox-jail/Cargo.toml b/crates/tinybox-jail/Cargo.toml index 07f5538b..2be21df6 100644 --- a/crates/tinybox-jail/Cargo.toml +++ b/crates/tinybox-jail/Cargo.toml @@ -44,6 +44,9 @@ windows-sys = { version = "0.61", features = [ "Win32_Security_Isolation", "Win32_Storage_FileSystem", "Win32_System_Memory", + "Win32_System_JobObjects", + "Win32_System_Pipes", + "Win32_System_SystemInformation", "Win32_System_Threading", ] } diff --git a/crates/tinybox-jail/README.md b/crates/tinybox-jail/README.md index 4f4042f7..879d4f5e 100644 --- a/crates/tinybox-jail/README.md +++ b/crates/tinybox-jail/README.md @@ -3,8 +3,8 @@ Directory-jail facade. Given a declarative description of a workspace (`Jail`), it spawns a child through an available sandbox backend. Linux (Landlock) and macOS (Seatbelt) backends are compiled and selected by -`pick_backend()`. The Windows AppContainer backend is still not compiled (see -below). When no backend is usable the default backend is `unsupported` +`pick_backend()`. On Windows the AppContainer backend serves the owned-child +API (`spawn_owned_captured`); the legacy `spawn` refuses there (see below). When no backend is usable the default backend is `unsupported` (`is_available() == false`, `spawn` fails with `Unsupported`); callers can explicitly select `NoopBackend` when unrestricted execution is intended. It is a per-process complement to the box-level isolation in `tinybox-linux`: @@ -44,7 +44,7 @@ spawns, never the core process itself. | `crates/tinybox-jail/src/bwrap/` | Bubblewrap backend (Linux only): user/pid/ipc/uts (+net) namespaces, grants as bind mounts, `deny_read` masked, `--die-with-parent`. `isolation()` is the minimum over configurations (`Process` with `--disable-userns`, else `None`); `Kernel` only per jail without `allow_net` (`isolation_for` / `is_suitable_for_untrusted_jail`; hosts must use the per-jail form). Never looks `bwrap` up on `PATH`; the probe is a sanity check, not authentication, and `with_program` skips the ownership check. The caller's environment and credentials travel in a private `--args` file, not in argv or any host process environment (`launcher.rs`), and inherited descriptors above 2 are closed by that launcher. No seccomp filter and no resource limits yet. Landlock is the fallback. Landlock is not layered inside because the kernel denies `mount` to a Landlock-restricted thread. | | `crates/tinybox-jail/src/linux.rs` | Landlock backend (Linux only, `landlock` feature, on by default). Applies the ruleset to a dedicated spawn thread so no `unsafe` `pre_exec` is needed. | | `crates/tinybox-jail/src/macos.rs` | Seatbelt backend via `sandbox-exec`. Compiled on every host so the profile renderer is unit-tested everywhere; selected only on macOS. | -| `crates/tinybox-jail/src/windows.rs` | AppContainer implementation; **not compiled**: it needs `unsafe` FFI the workspace forbids and cannot return a waitable `std::process::Child` yet. | +| `crates/tinybox-jail/src/windows.rs` | `AppContainerBackend`, compiled on Windows only under the one file-scoped `unsafe_code` allowance. Available through `spawn_owned_captured` / `spawn_owned_captured_with`; the legacy `spawn` returns `Unsupported` because it cannot return a waitable `std::process::Child`. | | `crates/tinybox-jail/src/registry.rs` | `JailRegistry` and `JailRecord`: multi-jail manager persisted to `index.json`, with atomic-rename writes and containment checks. | | `crates/tinybox-jail/src/{lib,jail,noop,macos,windows,registry}_tests.rs` | Sibling test suites, each `#[path]`-included from its source file. | @@ -100,8 +100,8 @@ This crate depends only on std and: (registry). - `landlock` crate: Linux backend, gated on the `landlock` cargo feature. -- `windows-sys`: Windows AppContainer FFI (Security/Isolation, Threading, - Memory APIs). +- `windows-sys`: Windows AppContainer, Job Object, process/threading, + pipe and memory APIs. - macOS backend shells out to the system binary `/usr/bin/sandbox-exec`. The Linux backend's docstring references `crate::security::landlock` as @@ -120,13 +120,14 @@ not import that module. as a real read grant and maps `allow_net` to the strictly outbound-only `internetClient` capability (LAN and inbound capabilities are deliberately excluded). -- Windows `spawn` currently returns an error after a successful launch. - `spawn_in_container` creates the process successfully but cannot bridge - the raw `HANDLE` into a `std::process::Child` (the needed - `FromRawHandle for Child` is unstable), so it closes the handle and - returns `io::ErrorKind::Unsupported`. See the TODO in `windows.rs`. The - Windows path is compile-checked but flagged as needing real-hardware - testing. +- Windows callers use `spawn_owned_captured` / `spawn_owned_captured_with`; + the legacy `spawn` API refuses before launch because Windows cannot create a + `std::process::Child` from a raw process handle. The owned AppContainer + backend retains process and Job Object handles, captures standard pipes, + applies CPU, aggregate memory and active-process limits when requested, and + terminates the whole job on cleanup. Job Objects do not enforce disk quotas, + so hard disk requirements are refused in preflight. The Windows path still + needs real-hardware validation. - macOS forwards only variables explicitly supplied with `Command::env` or `Command::envs`. The launcher clears the inherited environment because Rust exposes no getter for `Command::env_clear`; this prevents restoring parent diff --git a/crates/tinybox-jail/src/detect.rs b/crates/tinybox-jail/src/detect.rs index c1a8d801..18610535 100644 --- a/crates/tinybox-jail/src/detect.rs +++ b/crates/tinybox-jail/src/detect.rs @@ -52,6 +52,16 @@ fn candidates() -> Vec> { Vec::new() } +#[cfg(target_os = "windows")] +fn owned_candidates() -> Vec> { + vec![Arc::new(crate::windows::AppContainerBackend::new())] +} + +#[cfg(not(target_os = "windows"))] +fn owned_candidates() -> Vec> { + candidates() +} + /// Picks the first available OS backend (bubblewrap then Landlock on Linux, /// Seatbelt on macOS). When none works it logs a warning and returns an unsupported /// backend whose `is_available` is `false` and whose `spawn` fails with @@ -62,6 +72,52 @@ pub fn pick_backend() -> Arc { pick_from(candidates()) } +/// Picks the strongest available backend that can return an [`super::jail::OwnedChild`]. +#[must_use] +pub fn pick_owned_backend() -> Arc { + pick_owned_from(owned_candidates()) +} + +/// Select the first backend that can return an owned child. +fn pick_owned_from(backends: Vec>) -> Arc { + for backend in backends { + if backend.is_owned_available() { + log::debug!( + "[cwd_jail] selected owned-process sandbox {}", + backend.name() + ); + return backend; + } + log::debug!( + "[cwd_jail] owned-process sandbox {} is unavailable", + backend.name() + ); + } + log::warn!("[cwd_jail] no owned-process sandbox available; jailed spawns are unsupported"); + Arc::new(UnsupportedBackend) +} + +/// Returns the stable name of the backend selected by platform detection. +#[must_use] +pub fn backend_name() -> &'static str { + pick_backend().name() +} + +/// Reports whether this build and running Linux kernel support Landlock. +/// +/// This is always false off Linux and when the `landlock` feature is disabled. +#[must_use] +pub fn landlock_available() -> bool { + #[cfg(target_os = "linux")] + { + crate::linux::LandlockBackend::new().is_available() + } + #[cfg(not(target_os = "linux"))] + { + false + } +} + /// Select an available backend from the ordered platform candidates. fn pick_from(backends: Vec>) -> Arc { for backend in backends { diff --git a/crates/tinybox-jail/src/detect_tests.rs b/crates/tinybox-jail/src/detect_tests.rs index b943f705..b1988d95 100644 --- a/crates/tinybox-jail/src/detect_tests.rs +++ b/crates/tinybox-jail/src/detect_tests.rs @@ -17,6 +17,22 @@ fn unavailable_backend_rejects_spawning() { #[test] fn backend_detection_returns_a_backend() { assert_ne!(pick_backend().name().len(), 0); + assert_eq!(backend_name(), pick_backend().name()); +} + +#[cfg(target_os = "linux")] +#[test] +fn landlock_detection_matches_the_landlock_backend() { + assert_eq!( + landlock_available(), + crate::linux::LandlockBackend::new().is_available() + ); +} + +#[cfg(not(target_os = "linux"))] +#[test] +fn landlock_is_unavailable_off_linux() { + assert!(!landlock_available()); } #[test] @@ -80,3 +96,55 @@ fn selection_uses_the_first_available_candidate() { ]); assert_eq!(picked.name(), crate::noop::NOOP_BACKEND_NAME); } + +#[test] +fn owned_selection_skips_backends_without_owned_child_support() { + struct OwnedCandidate(&'static str, bool); + + impl JailBackend for OwnedCandidate { + fn name(&self) -> &'static str { + self.0 + } + fn is_available(&self) -> bool { + true + } + fn is_owned_available(&self) -> bool { + self.1 + } + fn spawn(&self, _: &Jail, _: Command) -> std::io::Result { + unreachable!("selection tests never spawn") + } + } + + let picked = pick_owned_from(vec![ + Arc::new(OwnedCandidate("legacy-only", false)), + Arc::new(OwnedCandidate("owned", true)), + Arc::new(OwnedCandidate("later-owned", true)), + ]); + assert_eq!(picked.name(), "owned"); + assert!(picked.is_owned_available()); +} + +#[test] +fn owned_selection_fails_closed_when_no_candidate_can_return_owned_child() { + struct LegacyOnly; + + impl JailBackend for LegacyOnly { + fn name(&self) -> &'static str { + "legacy-only" + } + fn is_available(&self) -> bool { + true + } + fn is_owned_available(&self) -> bool { + false + } + fn spawn(&self, _: &Jail, _: Command) -> std::io::Result { + unreachable!("selection tests never spawn") + } + } + + let picked = pick_owned_from(vec![Arc::new(LegacyOnly)]); + assert_eq!(picked.name(), UNSUPPORTED_BACKEND_NAME); + assert!(!picked.is_owned_available()); +} diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 5598df4f..3b9aa4dc 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -9,13 +9,6 @@ use std::fs::File; use std::path::{Path, PathBuf}; use std::process::{Child, Command}; -const RESOURCE_CONSTRAINTS: [tinybox_core::Constraint; 4] = [ - tinybox_core::Constraint::Cpu, - tinybox_core::Constraint::Memory, - tinybox_core::Constraint::Pids, - tinybox_core::Constraint::Disk, -]; - /// Declarative description of a directory jail. /// /// One `root` (read/write), zero or more extra `read_write` paths, zero or @@ -41,11 +34,13 @@ pub struct Jail { /// native module rejects overlaps that an allow-list backend cannot enforce. /// Missing paths are retained so backend rules can cover later creation. pub deny_read: Vec, - /// Optional hard CPU, memory, process and writable-disk limits. + /// Optional resource values to apply where the backend supports them. /// - /// Backends include the corresponding constraints in preflight. A backend - /// that does not enforce every requested limit refuses before spawning. + /// These values are defaults, not hard requirements. Use + /// [`hard_requirements`](Self::hard_requirements) to require enforcement. pub resources: Option, + /// Constraints the backend must enforce fully before spawning. + pub hard_requirements: Vec, /// Allow outbound network. Most agent tools need this; some risky tools /// (untrusted code execution) should disable it. pub allow_net: bool, @@ -67,6 +62,7 @@ impl Jail { read_write: Vec::new(), deny_read: Vec::new(), resources: None, + hard_requirements: Vec::new(), allow_net: true, allow_subprocess: true, label: label.into(), @@ -106,6 +102,35 @@ impl Jail { self } + /// Require the backend to fully enforce each listed constraint. + #[must_use] + pub fn with_hard_requirements( + mut self, + requirements: impl IntoIterator, + ) -> Self { + self.hard_requirements = requirements.into_iter().collect(); + self.deny_required() + } + + /// Copy resource defaults and hard constraints from a box specification. + /// Filesystem roots and grants remain explicit Jail data; a hard network + /// or subprocess requirement denies that access. + #[must_use] + pub fn with_box_spec(mut self, spec: &tinybox_core::BoxSpec) -> Self { + self.resources = Some(spec.resources); + self.hard_requirements.clone_from(&spec.hard_requirements); + self.deny_required() + } + + /// A hard `Network` or `Subprocess` requirement is a denial, so the flags + /// backends launch from agree with what preflight checked. + fn deny_required(mut self) -> Self { + use tinybox_core::Constraint; + self.allow_net &= !self.hard_requirements.contains(&Constraint::Network); + self.allow_subprocess &= !self.hard_requirements.contains(&Constraint::Subprocess); + self + } + /// Hard constraints implied by this jail's grants and denial flags. #[must_use] pub fn constraints(&self) -> Vec { @@ -117,13 +142,10 @@ impl Jail { if !self.allow_subprocess { constraints.push(Constraint::Subprocess); } - if self.resources.is_some() { - constraints.extend([ - Constraint::Cpu, - Constraint::Memory, - Constraint::Pids, - Constraint::Disk, - ]); + for constraint in &self.hard_requirements { + if !constraints.contains(constraint) { + constraints.push(*constraint); + } } constraints } @@ -346,6 +368,12 @@ pub trait JailBackend: Send + Sync { /// a backend. fn is_available(&self) -> bool; + /// Whether the backend can spawn through [`OwnedChild`]. Backends that + /// cannot expose a `std::process::Child` may override this independently. + fn is_owned_available(&self) -> bool { + self.is_available() + } + /// Declares implemented constraints; unknown backends claim nothing. fn constraint_support(&self) -> tinybox_core::ConstraintSupport { tinybox_core::ConstraintSupport::NONE @@ -397,6 +425,21 @@ pub trait JailBackend: Send + Sync { self.constraint_support().plan_check(&jail.constraints()) } + /// Inspect only the hard requirements explicitly listed on a box spec. + fn plan_check_spec(&self, spec: &tinybox_core::BoxSpec) -> tinybox_core::PlanCheck { + self.constraint_support() + .plan_check(&spec.hard_requirements) + } + + /// Refuse a box spec when any explicit hard requirement is unsupported. + /// + /// # Errors + /// Returns a typed refusal naming the first unsupported hard constraint. + fn require_spec(&self, spec: &tinybox_core::BoxSpec) -> tinybox_core::Result<()> { + self.constraint_support() + .require(self.name(), &spec.hard_requirements) + } + /// Refuse incomplete filesystem, network or subprocess confinement. /// /// # Errors @@ -406,20 +449,16 @@ pub trait JailBackend: Send + Sync { .require(self.name(), &jail.constraints()) } - /// Require full enforcement for every resource limit present on a jail. - /// - /// Resource limits are hard requirements even when the caller accepts - /// declared filesystem, network, or subprocess enforcement. + /// Validate resource values and preflight every explicit hard requirement. /// /// # Errors - /// Returns a typed refusal if any requested resource constraint is not - /// fully enforced. + /// Returns a typed refusal if any listed hard constraint is not fully enforced. fn require_resources(&self, jail: &Jail) -> tinybox_core::Result<()> { if let Some(resources) = jail.resources { resources.validate()?; - self.constraint_support() - .require(self.name(), &RESOURCE_CONSTRAINTS)?; } + self.constraint_support() + .require(self.name(), &jail.hard_requirements)?; Ok(()) } @@ -482,6 +521,22 @@ pub trait JailBackend: Send + Sync { OwnedChild::from_std(child) } } + + /// Spawn captured work with exactly the environment approved by the host. + /// The default clears `cmd`'s environment and applies `env` before + /// delegating; native launchers must build the child environment from + /// `env` instead of inheriting the parent's. + /// # Errors + /// Returns a confinement or native startup error. + fn spawn_owned_captured_with_env( + &self, + jail: &Jail, + mut cmd: Command, + env: &std::collections::BTreeMap, + ) -> std::io::Result { + cmd.env_clear().envs(env); + self.spawn_owned_captured(jail, cmd) + } } /// Exit information independent of a platform-specific child handle. diff --git a/crates/tinybox-jail/src/jail_tests.rs b/crates/tinybox-jail/src/jail_tests.rs index 94dc2f9f..f46d08f4 100644 --- a/crates/tinybox-jail/src/jail_tests.rs +++ b/crates/tinybox-jail/src/jail_tests.rs @@ -11,9 +11,19 @@ fn defaults_are_permissive() { } #[test] -fn requested_resources_become_hard_constraints() { +fn soft_resource_values_do_not_become_hard_constraints() { let mut jail = Jail::new("/tmp", "x"); jail.resources = Some(tinybox_core::Resources::DEFAULT); + assert_eq!( + jail.constraints(), + vec![tinybox_core::Constraint::Filesystem] + ); + jail = jail.with_hard_requirements([ + tinybox_core::Constraint::Cpu, + tinybox_core::Constraint::Memory, + tinybox_core::Constraint::Pids, + tinybox_core::Constraint::Disk, + ]); assert_eq!( jail.constraints(), vec![ @@ -26,6 +36,25 @@ fn requested_resources_become_hard_constraints() { ); } +#[test] +fn hard_network_and_subprocess_requirements_deny_that_access() { + use tinybox_core::{Constraint, HostRef, Placement, SandboxRef, WorkspaceSource}; + + let jail = + Jail::new("/tmp", "x").with_hard_requirements([Constraint::Network, Constraint::Cpu]); + assert!(!jail.allow_net); + assert!(jail.allow_subprocess); + let placement = Placement::new( + HostRef::new("local").expect("valid host ref"), + SandboxRef::new("noop").expect("valid sandbox ref"), + ); + let spec = tinybox_core::BoxSpec::new(placement, WorkspaceSource::LocalDir("/tmp".into())) + .with_hard_requirements([Constraint::Subprocess]); + let jail = Jail::new("/tmp", "x").with_box_spec(&spec); + assert!(jail.allow_net); + assert!(!jail.allow_subprocess); +} + #[test] fn deny_net_is_idempotent() { let j = Jail::new("/tmp", "x").deny_net().deny_net(); @@ -158,6 +187,36 @@ fn owned_captured_spawn_preserves_both_pipes_and_exit_status() -> std::io::Resul Ok(()) } +#[cfg(unix)] +#[test] +fn owned_spawn_with_an_approved_environment_drops_everything_else() -> std::io::Result<()> { + use std::io::Read; + + let root = tempfile::tempdir()?; + let jail = Jail::new(root.path(), "owned-approved-env"); + let mut command = std::process::Command::new("/bin/sh"); + command + .args([ + "-c", + r#"printf '%s|%s' "${APPROVED-}" "${UNAPPROVED-unset}""#, + ]) + .env("UNAPPROVED", "leaked"); + let approved = std::collections::BTreeMap::from([("APPROVED".to_owned(), "yes".to_owned())]); + let mut child = crate::spawn_owned_captured_with_backend_and_env( + &crate::NoopBackend, + &jail, + command, + &approved, + )?; + let mut stdout = String::new(); + child + .stdout_mut() + .expect("captured stdout") + .read_to_string(&mut stdout)?; + assert_eq!(stdout, "yes|unset"); + Ok(()) +} + #[cfg(windows)] #[test] fn owned_captured_spawn_does_not_claim_the_child_pid_as_a_process_group() -> std::io::Result<()> { diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index 8dda042a..eae82bed 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -15,11 +15,11 @@ //! | Linux | bubblewrap | `bwrap` namespaces; process isolation | //! | Linux | landlock | Fallback: kernel 5.13+ LSM, spawn thread | //! | macOS | seatbelt | `sandbox-exec -p '' …` | -//! | Windows | (not compiled)| `AppContainer`, pending a `Child` bridge | +//! | Windows | `AppContainer`| `AppContainer` ACLs + Job Object limits | //! | other | unsupported | Spawning is rejected | //! -//! The Windows backend is not compiled yet (see `windows.rs`); on Windows the -//! default backend is `unsupported` and a host must opt into `NoopBackend`. +//! Windows exposes `AppContainer` through the owned-child API because +//! `std::process::Child` cannot be constructed from Win32 process handles. //! //! ## Quick start //! @@ -59,13 +59,16 @@ pub mod registry; // module is plain `std` (it shells out to `sandbox-exec`), so it compiles // everywhere and its profile renderer is unit-tested on every host; it is only // *selected* on macOS. The Windows AppContainer module (`windows.rs`) is -// deliberately not compiled: it needs `unsafe` FFI the workspace forbids and -// cannot yet return a waitable `std::process::Child`. +// compiled on Windows only, under the single file-scoped `unsafe_code` +// allowance; it serves the owned-child API, since it cannot return a waitable +// `std::process::Child` for the legacy `spawn`. #[cfg(target_os = "linux")] pub mod bwrap; #[cfg(target_os = "linux")] pub mod linux; pub mod macos; +#[cfg(target_os = "windows")] +pub mod windows; #[cfg(target_os = "linux")] pub use bwrap::{BWRAP_BACKEND_NAME, BubblewrapBackend}; @@ -76,18 +79,28 @@ pub use linux::LandlockBackend; pub use macos::SeatbeltBackend; pub use noop::{NOOP_BACKEND_NAME, NoopBackend}; pub use registry::{IndexStore, JailRecord, JailRegistry}; +#[cfg(target_os = "windows")] +pub use windows::AppContainerBackend; use std::process::{Child, Command}; use std::sync::{Arc, OnceLock}; /// Cached default backend for the current platform. static DEFAULT_BACKEND: OnceLock> = OnceLock::new(); +static DEFAULT_OWNED_BACKEND: OnceLock> = OnceLock::new(); /// Returns the process-wide default backend, lazily auto-detected. pub fn default_backend() -> Arc { DEFAULT_BACKEND.get_or_init(detect::pick_backend).clone() } +/// Cached default backend for the process-owned child API. +pub fn default_owned_backend() -> Arc { + DEFAULT_OWNED_BACKEND + .get_or_init(detect::pick_owned_backend) + .clone() +} + /// Spawn `cmd` inside the jail described by `jail`, using the default backend. /// /// `jail.canonicalize()` is called once here so the backends never see @@ -151,6 +164,7 @@ pub fn spawn_captured_with( /// The backend is responsible for retaining the process handle while callers /// consume the returned standard pipes. This is the extensible path for /// process owners that cannot be represented by `std::process::Child`. +/// The child gets the environment `cmd` describes. /// # Errors /// Returns canonicalization, confinement, or native startup errors. pub fn spawn_owned_captured_with( @@ -158,11 +172,51 @@ pub fn spawn_owned_captured_with( jail: &Jail, cmd: Command, ) -> std::io::Result { + backend.spawn_owned_captured(&checked_owned_jail(backend, jail)?, cmd) +} + +/// Spawn captured work through an explicit backend and approved environment. +/// Native launchers must pass `env` as the child environment rather than +/// inheriting values from the host process. +/// # Errors +/// Returns canonicalization, hard-preflight, confinement, or startup errors. +pub fn spawn_owned_captured_with_backend_and_env( + backend: &dyn JailBackend, + jail: &Jail, + cmd: Command, + env: &std::collections::BTreeMap, +) -> std::io::Result { + backend.spawn_owned_captured_with_env(&checked_owned_jail(backend, jail)?, cmd, env) +} + +/// Canonicalize `jail` and run the hard preflight before an owned spawn. +fn checked_owned_jail(backend: &dyn JailBackend, jail: &Jail) -> std::io::Result { let mut jail = jail.clone(); jail.canonicalize()?; require_resources(backend, &jail)?; jail.validate_denials(backend.carves_denials(), &backend.implicit_grants())?; - backend.spawn_owned_captured(&jail, cmd) + Ok(jail) +} + +/// Spawn captured work with the detected backend that supports owned children. +/// The child gets the environment `cmd` describes. +/// +/// # Errors +/// Returns an error if no owned-child backend is available or jail setup fails. +pub fn spawn_owned_captured(jail: &Jail, cmd: Command) -> std::io::Result { + spawn_owned_captured_with(default_owned_backend().as_ref(), jail, cmd) +} + +/// Spawn captured work with the detected owned-child backend and approved environment. +/// +/// # Errors +/// Returns an error if no owned-child backend is available or jail setup fails. +pub fn spawn_owned_captured_with_env( + jail: &Jail, + cmd: Command, + env: &std::collections::BTreeMap, +) -> std::io::Result { + spawn_owned_captured_with_backend_and_env(default_owned_backend().as_ref(), jail, cmd, env) } fn require_resources(backend: &dyn JailBackend, jail: &Jail) -> std::io::Result<()> { diff --git a/crates/tinybox-jail/src/mod_tests.rs b/crates/tinybox-jail/src/mod_tests.rs index 1e4f66be..6dbb7f92 100644 --- a/crates/tinybox-jail/src/mod_tests.rs +++ b/crates/tinybox-jail/src/mod_tests.rs @@ -18,6 +18,73 @@ fn noop_backend_spawns_unrestricted() { assert!(status.success() || cfg!(windows)); } +#[cfg(unix)] +#[test] +fn default_owned_spawn_wrappers_preserve_owned_child_and_approved_environment() +-> std::io::Result<()> { + use std::io::Read; + + let backend = default_owned_backend(); + let root = tempfile::tempdir()?; + let jail = Jail::new(root.path(), "default-owned-wrapper"); + if !backend.is_owned_available() { + assert_eq!( + spawn_owned_captured(&jail, Command::new("/bin/true")) + .err() + .map(|error| error.kind()), + Some(std::io::ErrorKind::Unsupported) + ); + return Ok(()); + } + + let mut command = Command::new("/bin/sh"); + command + .args(["-c", "exit 0"]) + .env_clear() + .env("PATH", "/usr/bin:/bin"); + let mut child = spawn_owned_captured(&jail, command)?; + assert!(wait_owned_child(&mut child)?.success()); + + let env = std::collections::BTreeMap::from([ + ("PATH".to_owned(), "/usr/bin:/bin".to_owned()), + ( + "TINYBOX_APPROVED_CHILD_ENV".to_owned(), + "visible-to-child".to_owned(), + ), + ]); + let mut command = Command::new("/bin/sh"); + command + .args(["-c", "printf '%s' \"$TINYBOX_APPROVED_CHILD_ENV\""]) + .env_clear() + .envs(&env); + let mut child = spawn_owned_captured_with_env(&jail, command, &env)?; + let mut output = String::new(); + child + .take_stdout() + .expect("captured stdout") + .read_to_string(&mut output)?; + assert!(wait_owned_child(&mut child)?.success()); + assert_eq!(output, "visible-to-child"); + Ok(()) +} + +#[cfg(unix)] +fn wait_owned_child(child: &mut OwnedChild) -> std::io::Result { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if std::time::Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "owned child did not exit", + )); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + #[test] fn jail_builder_chains() { let j = Jail::new("/tmp", "x") @@ -42,6 +109,12 @@ fn facade_refuses_resource_limits_before_spawning() -> std::io::Result<()> { let marker = root.path().join("executed"); let mut jail = Jail::new(root.path(), "resources"); jail.resources = Some(tinybox_core::Resources::DEFAULT); + jail = jail.with_hard_requirements([ + tinybox_core::Constraint::Cpu, + tinybox_core::Constraint::Memory, + tinybox_core::Constraint::Pids, + tinybox_core::Constraint::Disk, + ]); let mut command = if cfg!(windows) { let mut command = Command::new("cmd"); command.args(["/C", "echo executed > executed"]); diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 49c16465..d11e5574 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -1,6 +1,6 @@ -//! Windows backend: AppContainer. +//! Windows backend: `AppContainer`. //! -//! AppContainer is Microsoft's strict process-isolation model — the same one +//! `AppContainer` is Microsoft's strict process-isolation model — the same one //! UWP apps and Edge's renderer use. Each container gets a unique SID; the //! process can only touch filesystem objects whose DACL explicitly grants //! that SID access. To jail an agent in `jail.root`: @@ -14,7 +14,7 @@ //! //! Things this backend deliberately does not do: //! -//! - Network capability requests. By default an AppContainer has *no* +//! - Network capability requests. By default an `AppContainer` has *no* //! network capabilities — we honor `jail.allow_net` by adding the //! `internetClient` capability (outbound client only, matching the //! `jail.allow_net` docstring of "Allow outbound network"). LAN / @@ -29,20 +29,25 @@ //! shipping. Win32 has many subtle wrong-trees you can bark up. #![cfg(target_os = "windows")] +#![allow(unsafe_code)] // Written for edition 2021, where the body of an `unsafe fn` is an unsafe // context. Each fn documents its own safety contract; re-scoping them into // per-call blocks is a separate, hardware-tested change. #![allow(unsafe_op_in_unsafe_fn)] -use std::ffi::OsStr; +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; use std::io; use std::os::windows::ffi::OsStrExt; -use std::os::windows::io::{FromRawHandle, OwnedHandle}; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use std::path::{Path, PathBuf}; use std::process::{Child, Command}; use std::ptr; -use windows_sys::Win32::Foundation::{CloseHandle, HLOCAL, LocalFree}; +use windows_sys::Win32::Foundation::{ + HANDLE, HANDLE_FLAG_INHERIT, HLOCAL, LocalFree, SetHandleInformation, WAIT_OBJECT_0, + WAIT_TIMEOUT, +}; use windows_sys::Win32::Security::Authorization::{ EXPLICIT_ACCESS_W, GetNamedSecurityInfoW, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW, SetNamedSecurityInfoW, TRUSTEE_IS_GROUP, TRUSTEE_IS_SID, TRUSTEE_W, @@ -52,7 +57,7 @@ use windows_sys::Win32::Security::Isolation::{ }; use windows_sys::Win32::Security::{ ACL, DACL_SECURITY_INFORMATION, DeriveCapabilitySidsFromName, FreeSid, PSID, - SECURITY_CAPABILITIES, SID_AND_ATTRIBUTES, + SECURITY_ATTRIBUTES, SECURITY_CAPABILITIES, SID_AND_ATTRIBUTES, }; use windows_sys::core::PWSTR; @@ -66,12 +71,13 @@ const GENERIC_WRITE: u32 = 0x4000_0000; const DELETE: u32 = 0x0001_0000; const OBJECT_INHERIT_ACE: u32 = 0x01; const CONTAINER_INHERIT_ACE: u32 = 0x02; -/// SE_GROUP_ENABLED — marks a SID in a `SID_AND_ATTRIBUTES` entry as active. -/// Required for capability SIDs passed to AppContainer SECURITY_CAPABILITIES. +/// `SE_GROUP_ENABLED` — marks a SID in a `SID_AND_ATTRIBUTES` entry as active. +/// Required for capability SIDs passed to `AppContainer` `SECURITY_CAPABILITIES`. /// Source: WinNT.h. const SE_GROUP_ENABLED: u32 = 0x0000_0004; +const CREATE_UNICODE_ENVIRONMENT: u32 = 0x0000_0400; -/// AppContainer capability names granted when `jail.allow_net == true`. +/// `AppContainer` capability names granted when `jail.allow_net == true`. /// /// Per `jail.rs`, `allow_net` is documented as **"Allow outbound network"** /// — so the capability set here is strictly outbound-client only: @@ -96,22 +102,34 @@ const SE_GROUP_ENABLED: u32 = 0x0000_0004; /// dedicated `Jail` flag (e.g. `allow_private_lan_server`) and extend /// this list conditionally — do NOT silently expand `allow_net`. const NET_CAPABILITY_NAMES: &[&str] = &["internetClient"]; +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_CPU_RATE_CONTROL_ENABLE, + JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, + JOB_OBJECT_LIMIT_JOB_MEMORY, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_CPU_RATE_CONTROL_INFORMATION, JOBOBJECT_CPU_RATE_CONTROL_INFORMATION_0, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectCpuRateControlInformation, + JobObjectExtendedLimitInformation, SetInformationJobObject, TerminateJobObject, +}; use windows_sys::Win32::System::Memory::{LPTR, LocalAlloc}; +use windows_sys::Win32::System::Pipes::CreatePipe; +use windows_sys::Win32::System::SystemInformation::{GetSystemInfo, SYSTEM_INFO}; use windows_sys::Win32::System::Threading::{ - CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, - InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, - PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, STARTUPINFOEXW, STARTUPINFOW, - UpdateProcThreadAttribute, + CREATE_SUSPENDED, CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, + GetExitCodeProcess, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, + PROCESS_INFORMATION, ResumeThread, STARTF_USESTDHANDLES, STARTUPINFOEXW, STARTUPINFOW, + TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, }; use super::jail::{Jail, JailBackend}; -/// Windows AppContainer backend. +/// Windows `AppContainer` backend. #[derive(Debug)] pub struct AppContainerBackend; impl AppContainerBackend { /// Creates the backend. + #[must_use] pub fn new() -> Self { Self } @@ -129,33 +147,261 @@ impl JailBackend for AppContainerBackend { } fn is_available(&self) -> bool { - // AppContainer is *reachable* on every supported Windows (8+ / Server - // 2012+), but the spawn path in `spawn_in_container` can't yet return - // a `std::process::Child` — after `CreateProcessW` succeeds it must - // reply `Err(io::ErrorKind::Unsupported)` (see the TODO / `Unsupported` - // return at the end of `spawn_in_container`). Reporting the backend as - // available anyway strands the successfully-spawned process on the - // caller side: `execute_local_jail` bubbles the `Err` up as a spawn - // failure and never `wait`s on the running `cmd.exe`, so it - // orphan-runs against the redirected stdout/stderr files. Report - // unavailable until `Child` bridging lands so `pick_backend()` / - // `execute_local_jail` route through `NoopBackend`, which returns a - // real waitable `Child`. (PR #4723 review — #4705.) + // std::process::Child cannot wrap a Win32 handle. Legacy callers use + // the owned-child availability path instead. false } + fn is_owned_available(&self) -> bool { + true + } + + fn spawn_owned_captured( + &self, + jail: &Jail, + cmd: Command, + ) -> io::Result { + let env = command_environment(&cmd)?; + self.spawn_owned_captured_with_env(jail, cmd, &env) + } + + fn spawn_owned_captured_with_env( + &self, + jail: &Jail, + cmd: Command, + env: &BTreeMap, + ) -> io::Result { + unsafe { spawn_in_container(jail, &cmd, env) } + } + + fn constraint_support(&self) -> tinybox_core::ConstraintSupport { + windows_constraint_support(self.is_owned_available()) + } + fn spawn(&self, jail: &Jail, cmd: Command) -> io::Result { - if !self.is_available() { + let _ = (jail, cmd); + Err(io::Error::new( + io::ErrorKind::Unsupported, + "AppContainer requires the owned-child spawn API on Windows", + )) + } +} + +/// The variables `cmd` sets explicitly. An `AppContainer` child inherits +/// nothing from the parent, so a removal has nothing to remove. +fn command_environment(cmd: &Command) -> io::Result> { + cmd.get_envs() + .filter_map(|(key, value)| Some((key, value?))) + .map(|(key, value)| match (key.to_str(), value.to_str()) { + (Some(key), Some(value)) => Ok((key.to_owned(), value.to_owned())), + _ => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Windows environment entries must be Unicode", + )), + }) + .collect() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct JobObjectLimitPlan { + process_memory_bytes: Option, + active_processes: Option, + cpu_rate: Option, + kill_on_close: bool, +} + +fn job_object_limit_plan( + resources: tinybox_core::Resources, + processor_count: u32, + hard_requirements: &[tinybox_core::Constraint], +) -> io::Result { + resources + .validate() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let processor_count = u64::from(processor_count.max(1)); + let total_cpu_millis = processor_count * 1_000; + let cpu_rate = if hard_requirements.contains(&tinybox_core::Constraint::Cpu) { + // Rounding up to the smallest rate would grant more than requested. + let rate = (u64::from(resources.cpu_millis) * 10_000) / total_cpu_millis; + if rate == 0 { return Err(io::Error::new( - io::ErrorKind::Unsupported, - "AppContainer cannot return a waitable process handle", + io::ErrorKind::InvalidInput, + "CPU limit is below the smallest Windows job CPU rate", )); } - unsafe { spawn_in_container(jail, cmd) } + Some(rate.min(10_000) as u32) + } else { + None + }; + let process_memory_bytes = if hard_requirements.contains(&tinybox_core::Constraint::Memory) { + Some(usize::try_from(resources.memory_bytes).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "memory limit exceeds Windows address size", + ) + })?) + } else { + None + }; + Ok(JobObjectLimitPlan { + process_memory_bytes, + active_processes: hard_requirements + .contains(&tinybox_core::Constraint::Pids) + .then_some(resources.pids_max), + cpu_rate, + kill_on_close: true, + }) +} + +fn windows_constraint_support(available: bool) -> tinybox_core::ConstraintSupport { + use tinybox_core::{Constraint, ConstraintSupport, Enforcement}; + if !available { + return ConstraintSupport::NONE; + } + [ + Constraint::Filesystem, + Constraint::Network, + Constraint::Cpu, + Constraint::Memory, + Constraint::Pids, + ] + .into_iter() + .fold(ConstraintSupport::NONE, |support, constraint| { + support.with(constraint, Enforcement::Enforced) + }) +} + +unsafe fn create_job_object( + resources: Option, + hard_requirements: &[tinybox_core::Constraint], +) -> io::Result { + let mut system_info: SYSTEM_INFO = std::mem::zeroed(); + GetSystemInfo(&raw mut system_info); + let plan = resources + .map(|resources| { + job_object_limit_plan( + resources, + system_info.dwNumberOfProcessors, + hard_requirements, + ) + }) + .transpose()?; + let job = CreateJobObjectW(ptr::null(), ptr::null()); + if job.is_null() { + return Err(io::Error::last_os_error()); + } + let job = OwnedHandle::from_raw_handle(job.cast()); + + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + let mut flags = if plan.is_none_or(|limits| limits.kill_on_close) { + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + } else { + 0 + }; + if let Some(plan) = plan { + if let Some(active_processes) = plan.active_processes { + flags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS; + limits.BasicLimitInformation.ActiveProcessLimit = active_processes; + } + if let Some(memory_bytes) = plan.process_memory_bytes { + flags |= JOB_OBJECT_LIMIT_JOB_MEMORY; + limits.JobMemoryLimit = memory_bytes; + } + } + limits.BasicLimitInformation.LimitFlags = flags; + if SetInformationJobObject( + job.as_raw_handle().cast(), + JobObjectExtendedLimitInformation, + (&raw const limits).cast(), + size_as_u32(std::mem::size_of_val(&limits))?, + ) == 0 + { + return Err(io::Error::last_os_error()); + } + + if let Some(cpu_rate) = plan.and_then(|plan| plan.cpu_rate) { + let cpu = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION { + ControlFlags: JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP, + Anonymous: JOBOBJECT_CPU_RATE_CONTROL_INFORMATION_0 { CpuRate: cpu_rate }, + }; + if SetInformationJobObject( + job.as_raw_handle().cast(), + JobObjectCpuRateControlInformation, + (&raw const cpu).cast(), + size_as_u32(std::mem::size_of_val(&cpu))?, + ) == 0 + { + return Err(io::Error::last_os_error()); + } } + Ok(job) +} + +fn size_as_u32(value: usize) -> io::Result { + u32::try_from(value).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "Win32 structure size exceeds u32", + ) + }) } -unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result { +fn environment_block( + env: &BTreeMap, + local_app_data: &OsStr, + system_root: &OsStr, +) -> io::Result> { + let mut entries: Vec<(OsString, OsString)> = env + .iter() + .filter(|(key, _)| { + !key.eq_ignore_ascii_case("LOCALAPPDATA") && !key.eq_ignore_ascii_case("SYSTEMROOT") + }) + .map(|(key, value)| (key.into(), value.into())) + .collect(); + // CreateProcessW needs the profile root to resolve the AppContainer + // profile. Forward this single OS-required value even when the requested + // child environment is otherwise empty; do not inherit the parent block. + entries.push((OsString::from("LOCALAPPDATA"), local_app_data.to_owned())); + // Windows child processes need SystemRoot to locate system runtime files. + // This is the only other parent value forwarded into the block. + entries.push((OsString::from("SystemRoot"), system_root.to_owned())); + entries.sort_by_key(|(key, _)| key.to_string_lossy().to_ascii_lowercase()); + let mut block = Vec::new(); + for (key, value) in entries { + if key.is_empty() + || key.to_string_lossy().contains('=') + || key.to_string_lossy().contains('\0') + || value.to_string_lossy().contains('\0') + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Windows environment entries must have valid names and no NUL bytes", + )); + } + block.extend(key.encode_wide()); + block.push(u16::from(b'=')); + block.extend(value.encode_wide()); + block.push(0); + } + block.push(0); + Ok(block) +} + +fn required_environment_variable(name: &'static str) -> io::Result { + std::env::var_os(name).ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("{name} is required to launch an AppContainer process"), + ) + }) +} + +#[allow(clippy::too_many_lines)] // Win32 startup structures must share their backing lifetimes. +unsafe fn spawn_in_container( + jail: &Jail, + cmd: &Command, + env: &BTreeMap, +) -> io::Result { // 1. Create or open the AppContainer profile and obtain its SID. let profile_name = sanitize_profile_name(&jail.label); let wide_name = to_wide(&profile_name); @@ -169,14 +415,15 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result { desc.as_ptr(), ptr::null_mut(), 0, - &mut sid, + &raw mut sid, ); if hr != 0 { // 0x800700B7 = HRESULT_FROM_WIN32(ERROR_ALREADY_EXISTS). // In that case derive the SID from the existing profile. - if hr as u32 == 0x800700B7 { + if hr.cast_unsigned() == 0x8007_00B7 { let mut existing: PSID = ptr::null_mut(); - let drv = DeriveAppContainerSidFromAppContainerName(wide_name.as_ptr(), &mut existing); + let drv = + DeriveAppContainerSidFromAppContainerName(wide_name.as_ptr(), &raw mut existing); if drv != 0 { return Err(io::Error::from_raw_os_error(drv)); } @@ -197,14 +444,14 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result { // access by default; honour `jail.allow_net` by deriving the // capability SIDs in `NET_CAPABILITY_NAMES` via // `DeriveCapabilitySidsFromName` and attaching them as - // SE_GROUP_ENABLED entries. `_cap_derivations` owns the + // SE_GROUP_ENABLED entries. `cap_derivations` owns the // OS-allocated SID buffers and frees them via Drop after // CreateProcessW has captured the values into the child process. // `cap_attrs` must outlive CreateProcessW (the attribute list // references it by pointer) — it stays in scope until the end of // `spawn_in_container`. let mut cap_attrs: Vec = Vec::new(); - let mut _cap_derivations: Vec = Vec::new(); + let mut cap_derivations: Vec = Vec::new(); if jail.allow_net { let mut any_derived = false; for &name in NET_CAPABILITY_NAMES { @@ -217,7 +464,7 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result { }); any_derived = true; } - _cap_derivations.push(deriv); + cap_derivations.push(deriv); } Err(e) => log::warn!( "[cwd_jail] DeriveCapabilitySidsFromName({name}) failed: \ @@ -240,26 +487,26 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result { } else { cap_attrs.as_mut_ptr() }, - CapabilityCount: cap_attrs.len() as u32, + CapabilityCount: size_as_u32(cap_attrs.len())?, Reserved: 0, }; // 4. Build STARTUPINFOEXW with the security-capabilities attribute. let mut size: usize = 0; - InitializeProcThreadAttributeList(ptr::null_mut(), 1, 0, &mut size); + InitializeProcThreadAttributeList(ptr::null_mut(), 2, 0, &raw mut size); let attr_buf = LocalAlloc(LPTR, size) as LPPROC_THREAD_ATTRIBUTE_LIST; if attr_buf.is_null() { return Err(io::Error::last_os_error()); } let _attr_guard = AttrListGuard(attr_buf); - if InitializeProcThreadAttributeList(attr_buf, 1, 0, &mut size) == 0 { + if InitializeProcThreadAttributeList(attr_buf, 2, 0, &raw mut size) == 0 { return Err(io::Error::last_os_error()); } if UpdateProcThreadAttribute( attr_buf, 0, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES as usize, - &mut caps as *mut _ as *mut _, + (&raw mut caps).cast(), std::mem::size_of::(), ptr::null_mut(), ptr::null_mut(), @@ -269,13 +516,46 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result { } let mut si: STARTUPINFOEXW = std::mem::zeroed(); - si.StartupInfo.cb = std::mem::size_of::() as u32; + si.StartupInfo.cb = size_as_u32(std::mem::size_of::())?; si.lpAttributeList = attr_buf; + // Inherit only the child-facing pipe ends. The host keeps the opposite + // ends as ordinary Files on the OwnedChild. + let (stdin_read, stdin_write) = create_pipe_pair()?; + let (stdout_read, stdout_write) = create_pipe_pair()?; + let (stderr_read, stderr_write) = create_pipe_pair()?; + set_noninheritable(&stdin_write)?; + set_noninheritable(&stdout_read)?; + set_noninheritable(&stderr_read)?; + si.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + si.StartupInfo.hStdInput = stdin_read.as_raw_handle().cast(); + si.StartupInfo.hStdOutput = stdout_write.as_raw_handle().cast(); + si.StartupInfo.hStdError = stderr_write.as_raw_handle().cast(); + let inherited_handles: [HANDLE; 3] = [ + stdin_read.as_raw_handle().cast(), + stdout_write.as_raw_handle().cast(), + stderr_write.as_raw_handle().cast(), + ]; + if UpdateProcThreadAttribute( + attr_buf, + 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, + inherited_handles.as_ptr() as *mut _, + std::mem::size_of_val(&inherited_handles), + ptr::null_mut(), + ptr::null_mut(), + ) == 0 + { + return Err(io::Error::last_os_error()); + } + // 5. Build the command line and current directory. - let cmdline = build_command_line(&cmd); + let cmdline = build_command_line(cmd); let mut cmdline_w = to_wide(&cmdline); let cwd_w = cmd.get_current_dir().map(|p| to_wide(&p.to_string_lossy())); + let local_app_data = required_environment_variable("LOCALAPPDATA")?; + let system_root = required_environment_variable("SystemRoot")?; + let mut environment = environment_block(env, &local_app_data, &system_root)?; let mut pi: PROCESS_INFORMATION = std::mem::zeroed(); let ok = CreateProcessW( @@ -283,40 +563,135 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result { cmdline_w.as_mut_ptr() as PWSTR, ptr::null_mut(), ptr::null_mut(), - 0, // bInheritHandles - EXTENDED_STARTUPINFO_PRESENT, - ptr::null_mut(), - cwd_w.as_ref().map(|s| s.as_ptr()).unwrap_or(ptr::null()), - &mut si as *mut _ as *mut STARTUPINFOW, - &mut pi, + 1, // bInheritHandles: only the three inheritable child pipe ends. + EXTENDED_STARTUPINFO_PRESENT | CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT, + environment.as_mut_ptr().cast(), + cwd_w.as_ref().map_or(ptr::null(), std::vec::Vec::as_ptr), + (&raw mut si).cast::(), + &raw mut pi, ); if ok == 0 { return Err(io::Error::last_os_error()); } - // 6. Wrap the raw process handle in a std `Child`. - // - // std::process::Child has no public constructor from a raw HANDLE on - // Windows. We close the thread handle (we don't need it) and leak the - // process handle into an OwnedHandle so the caller can at least wait - // via the OS. Returning a `Child` requires nightly's `FromRawHandle for - // Child`, which is unstable. For now we close the process handle too - // and return an error explaining the limitation — see TODO below. - CloseHandle(pi.hThread); - - let _process_handle = OwnedHandle::from_raw_handle(pi.hProcess as _); - - // TODO: bridge OwnedHandle -> std::process::Child once - // `std::os::windows::process::ChildExt::from_raw_handle` lands, OR - // expose a custom `OpenhumanChild` from the cwd_jail module that - // mirrors the bits of `Child` callers actually need (id, wait, kill). - Err(io::Error::new( - io::ErrorKind::Unsupported, - "AppContainer spawn succeeded but cannot yet be returned as std::process::Child; \ - see TODO in crates/tinybox-jail/src/windows.rs", + let process = OwnedHandle::from_raw_handle(pi.hProcess.cast()); + let thread = OwnedHandle::from_raw_handle(pi.hThread.cast()); + let resource_requirements: Vec<_> = jail + .hard_requirements + .iter() + .copied() + .filter(|constraint| { + matches!( + constraint, + tinybox_core::Constraint::Cpu + | tinybox_core::Constraint::Memory + | tinybox_core::Constraint::Pids + ) + }) + .collect(); + let resources = jail.resources.or_else(|| { + jail.hard_requirements + .iter() + .any(|constraint| { + matches!( + constraint, + tinybox_core::Constraint::Cpu + | tinybox_core::Constraint::Memory + | tinybox_core::Constraint::Pids + | tinybox_core::Constraint::Disk + ) + }) + .then_some(tinybox_core::Resources::DEFAULT) + }); + let job = match create_job_object(resources, &resource_requirements) { + Ok(job) => job, + Err(error) => { + let _ = TerminateProcess(process.as_raw_handle().cast(), 1); + return Err(error); + } + }; + if AssignProcessToJobObject(job.as_raw_handle().cast(), process.as_raw_handle().cast()) == 0 { + let error = io::Error::last_os_error(); + let _ = TerminateProcess(process.as_raw_handle().cast(), 1); + return Err(error); + } + if ResumeThread(thread.as_raw_handle().cast()) == u32::MAX { + let error = io::Error::last_os_error(); + let _ = TerminateJobObject(job.as_raw_handle().cast(), 1); + return Err(error); + } + + Ok(super::jail::OwnedChild::new( + AppContainerOwnedProcess { process, job }, + Some(std::fs::File::from(stdin_write)), + Some(std::fs::File::from(stdout_read)), + Some(std::fs::File::from(stderr_read)), )) } +unsafe fn create_pipe_pair() -> io::Result<(OwnedHandle, OwnedHandle)> { + let mut read = ptr::null_mut(); + let mut write = ptr::null_mut(); + let security = SECURITY_ATTRIBUTES { + nLength: size_as_u32(std::mem::size_of::())?, + lpSecurityDescriptor: ptr::null_mut(), + bInheritHandle: 1, + }; + if CreatePipe(&raw mut read, &raw mut write, &raw const security, 0) == 0 { + return Err(io::Error::last_os_error()); + } + Ok(( + OwnedHandle::from_raw_handle(read.cast()), + OwnedHandle::from_raw_handle(write.cast()), + )) +} + +unsafe fn set_noninheritable(handle: &OwnedHandle) -> io::Result<()> { + if SetHandleInformation(handle.as_raw_handle().cast(), HANDLE_FLAG_INHERIT, 0) == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +struct AppContainerOwnedProcess { + process: OwnedHandle, + job: OwnedHandle, +} + +impl super::jail::OwnedProcess for AppContainerOwnedProcess { + fn id(&self) -> Option { + Some(unsafe { + windows_sys::Win32::System::Threading::GetProcessId(self.process.as_raw_handle().cast()) + }) + } + + fn try_wait(&mut self) -> io::Result> { + let wait = unsafe { WaitForSingleObject(self.process.as_raw_handle().cast(), 0) }; + if wait == WAIT_TIMEOUT { + return Ok(None); + } + if wait != WAIT_OBJECT_0 { + return Err(io::Error::last_os_error()); + } + let mut exit_code = 0; + if unsafe { GetExitCodeProcess(self.process.as_raw_handle().cast(), &raw mut exit_code) } + == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(Some(super::jail::ChildStatus::from_code(Some( + exit_code.cast_signed(), + )))) + } + + fn terminate(&mut self) -> io::Result<()> { + if unsafe { TerminateJobObject(self.job.as_raw_handle().cast(), 1) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } +} + /// The DACL grants a jail needs: the root and every `read_write` path get /// read, write and delete; every `read_only` path gets read. fn path_grants(jail: &Jail) -> Vec<(&Path, u32)> { @@ -353,12 +728,12 @@ unsafe fn grant_sid_access(path: &Path, sid: PSID, access: u32) -> io::Result<() DACL_SECURITY_INFORMATION, ptr::null_mut(), ptr::null_mut(), - &mut existing_dacl, + &raw mut existing_dacl, ptr::null_mut(), - &mut sd_ptr, + &raw mut sd_ptr, ); if rc != 0 { - return Err(io::Error::from_raw_os_error(rc as i32)); + return Err(io::Error::from_raw_os_error(rc.cast_signed())); } let _sd_guard = LocalGuard(sd_ptr as HLOCAL); let _ = dacl_present; @@ -372,18 +747,18 @@ unsafe fn grant_sid_access(path: &Path, sid: PSID, access: u32) -> io::Result<() MultipleTrusteeOperation: 0, TrusteeForm: TRUSTEE_IS_SID, TrusteeType: TRUSTEE_IS_GROUP, - ptstrName: sid as *mut _, + ptstrName: sid.cast(), }; let mut new_acl: *mut ACL = ptr::null_mut(); - let rc = SetEntriesInAclW(1, &ea, existing_dacl, &mut new_acl); + let rc = SetEntriesInAclW(1, &raw const ea, existing_dacl, &raw mut new_acl); if rc != 0 { - return Err(io::Error::from_raw_os_error(rc as i32)); + return Err(io::Error::from_raw_os_error(rc.cast_signed())); } let _acl_guard = LocalGuard(new_acl as HLOCAL); let rc = SetNamedSecurityInfoW( - path_w.as_ptr() as PWSTR, + path_w.as_ptr().cast_mut(), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, ptr::null_mut(), @@ -392,7 +767,7 @@ unsafe fn grant_sid_access(path: &Path, sid: PSID, access: u32) -> io::Result<() ptr::null_mut(), ); if rc != 0 { - return Err(io::Error::from_raw_os_error(rc as i32)); + return Err(io::Error::from_raw_os_error(rc.cast_signed())); } Ok(()) } @@ -422,7 +797,7 @@ fn push_arg(out: &mut String, a: &str) { match c { '\\' => backslashes += 1, '"' => { - for _ in 0..(backslashes * 2 + 1) { + for _ in 0..=(backslashes * 2) { out.push('\\'); } out.push('"'); @@ -452,7 +827,12 @@ fn to_wide(s: &str) -> Vec { fn sanitize_profile_name(label: &str) -> String { // AppContainer profile names must be ≤ 64 chars and use a restricted - // charset. Keep ASCII alnum + dot; map everything else to `_`. + // charset. Keep ASCII alnum + dot and map everything else to `_`; then a + // hash of the full label keeps labels that sanitize or truncate alike + // (`a-b` and `a_b`) on separate profiles, and so separate SIDs. + let hash = label.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3) + }); let mut s: String = label .chars() .map(|c| { @@ -463,8 +843,9 @@ fn sanitize_profile_name(label: &str) -> String { } }) .collect(); - s.truncate(60); - format!("openhuman.{s}") + // "openhuman." (10) + 37 + "." (1) + 16 hex digits = 64. + s.truncate(37); + format!("openhuman.{s}.{hash:016x}") } struct SidGuard(PSID); @@ -509,7 +890,7 @@ impl Drop for LocalGuard { /// reverse order they were allocated. /// /// The `capability_sids` are the ones we actually want for an -/// AppContainer's `SECURITY_CAPABILITIES.Capabilities`; the +/// `AppContainer`'s `SECURITY_CAPABILITIES.Capabilities`; the /// `group_sids` come along because the Win32 API returns both, and we /// hold them here purely so we can free them. struct CapabilityDerivation { @@ -550,7 +931,11 @@ impl Drop for CapabilityDerivation { /// /// # Safety /// FFI into `userenv.dll`. Safe to call from any thread on Windows. -unsafe fn derive_capability(name: &str) -> io::Result { +fn derive_capability(name: &str) -> io::Result { + unsafe { derive_capability_ffi(name) } +} + +unsafe fn derive_capability_ffi(name: &str) -> io::Result { let name_w = to_wide(name); let mut group_sids: *mut PSID = ptr::null_mut(); let mut group_count: u32 = 0; @@ -558,10 +943,10 @@ unsafe fn derive_capability(name: &str) -> io::Result { let mut cap_count: u32 = 0; let ok = DeriveCapabilitySidsFromName( name_w.as_ptr(), - &mut group_sids, - &mut group_count, - &mut cap_sids, - &mut cap_count, + &raw mut group_sids, + &raw mut group_count, + &raw mut cap_sids, + &raw mut cap_count, ); if ok == 0 { return Err(io::Error::last_os_error()); diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index 97bde31a..f322fccc 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -20,7 +20,7 @@ fn derive_capability_resolves_well_known_internet_client() { // capabilities present on every supported Windows version // (10+). DeriveCapabilitySidsFromName must succeed and return // at least one non-null capability SID. - let deriv = unsafe { derive_capability("internetClient") } + let deriv = derive_capability("internetClient") .expect("internetClient must resolve on any supported Windows"); assert!( !deriv.capability_sids.is_empty(), @@ -40,16 +40,32 @@ fn sanitize_profile_name_keeps_alnum_and_dot() { assert!(s.starts_with("openhuman.")); // hyphen mapped to underscore assert!(s.contains("agent.delegate_7")); - assert!(s.len() <= 70); // "openhuman." (10) + label (≤60) + assert!(s.len() <= 64); } #[test] fn sanitize_profile_name_truncates_long_labels() { let long: String = "a".repeat(200); let s = sanitize_profile_name(&long); - // Per the in-function comment: truncate to 60 then prefix. - assert!(s.len() <= 70); + assert_eq!(s.len(), 64); assert!(s.starts_with("openhuman.")); + assert_ne!(s, sanitize_profile_name(&"a".repeat(201))); +} + +#[test] +fn labels_that_sanitize_alike_get_separate_profiles() { + assert_ne!(sanitize_profile_name("a-b"), sanitize_profile_name("a_b")); + assert_eq!(sanitize_profile_name("a-b"), sanitize_profile_name("a-b")); +} + +#[test] +fn a_command_environment_without_a_map_reaches_the_child_map() { + let mut command = Command::new("cmd"); + command.env("TOKEN", "approved").env_remove("GONE"); + assert_eq!( + command_environment(&command).expect("Unicode environment"), + BTreeMap::from([("TOKEN".to_owned(), "approved".to_owned())]) + ); } /// PR #4723 review — `AppContainerBackend::is_available()` must @@ -104,3 +120,363 @@ fn path_grants_keep_write_access_on_paths_also_listed_read_only() { ] ); } + +#[test] +fn job_object_limit_plan_sets_memory_process_cpu_and_kill_on_close() { + use tinybox_core::Resources; + + let limits = job_object_limit_plan( + Resources { + cpu_millis: 1_500, + memory_bytes: 512 * 1024 * 1024, + pids_max: 17, + disk_bytes: Resources::DEFAULT.disk_bytes, + }, + 4, + &[ + tinybox_core::Constraint::Cpu, + tinybox_core::Constraint::Memory, + tinybox_core::Constraint::Pids, + ], + ) + .expect("valid Windows Job Object limits"); + + assert_eq!(limits.process_memory_bytes, Some(512 * 1024 * 1024)); + assert_eq!(limits.active_processes, Some(17)); + assert_eq!(limits.cpu_rate, Some(3_750)); + assert!(limits.kill_on_close); +} + +#[test] +fn a_cpu_limit_below_the_smallest_job_rate_is_refused() { + let resources = tinybox_core::Resources { + cpu_millis: 1, + ..tinybox_core::Resources::DEFAULT + }; + assert!(job_object_limit_plan(resources, 128, &[tinybox_core::Constraint::Cpu]).is_err()); + assert!(job_object_limit_plan(resources, 1, &[tinybox_core::Constraint::Cpu]).is_ok()); +} + +#[test] +fn soft_resource_values_do_not_create_hard_job_limits() { + let limits = job_object_limit_plan(tinybox_core::Resources::DEFAULT, 8, &[]) + .expect("valid soft defaults"); + assert_eq!(limits.process_memory_bytes, None); + assert_eq!(limits.active_processes, None); + assert_eq!(limits.cpu_rate, None); + assert!(limits.kill_on_close); +} + +fn cmd_exe() -> PathBuf { + PathBuf::from(std::env::var_os("SystemRoot").expect("Windows system root")) + .join("System32") + .join("cmd.exe") +} + +#[test] +fn explicit_environment_block_is_sorted_and_does_not_add_parent_values() { + let local_app_data = std::ffi::OsStr::new(r"C:\Users\runner\AppData\Local"); + let system_root = std::ffi::OsStr::new(r"C:\Windows"); + let env = BTreeMap::from([ + ("SYSTEMROOT".to_owned(), "request-override".to_owned()), + ("LOCALAPPDATA".to_owned(), "request-override".to_owned()), + ("TINYBOX_APPROVED".to_owned(), "visible".to_owned()), + ("Z_LAST".to_owned(), "last".to_owned()), + ]); + let block = + environment_block(&env, local_app_data, system_root).expect("valid explicit environment"); + let block = String::from_utf16_lossy(&block); + assert!(block.starts_with( + "LOCALAPPDATA=C:\\Users\\runner\\AppData\\Local\0SystemRoot=C:\\Windows\0TINYBOX_APPROVED=visible\0Z_LAST=last\0\0" + )); + assert!(!block.contains("PATH=")); + assert!(!block.contains("USERPROFILE=")); + assert!(!block.contains("request-override")); +} + +#[test] +fn empty_environment_block_has_the_required_double_terminator() { + let local_app_data = std::ffi::OsStr::new(r"C:\Users\runner\AppData\Local"); + let system_root = std::ffi::OsStr::new(r"C:\Windows"); + let block = environment_block(&BTreeMap::new(), local_app_data, system_root).unwrap(); + assert_eq!( + String::from_utf16_lossy(&block), + "LOCALAPPDATA=C:\\Users\\runner\\AppData\\Local\0SystemRoot=C:\\Windows\0\0" + ); +} + +#[test] +fn appcontainer_owned_spawn_uses_only_the_approved_environment() -> io::Result<()> { + use std::io::Read; + + let root = tempfile::tempdir()?; + let backend = AppContainerBackend::new(); + let mut command = Command::new(cmd_exe()); + command.args(["/C", "echo %TINYBOX_APPROVED_ENV%"]); + let approved = BTreeMap::from([( + "TINYBOX_APPROVED_ENV".to_owned(), + "approved-value".to_owned(), + )]); + let jail = Jail::new(root.path(), "approved-env").deny_net(); + let mut child = + crate::spawn_owned_captured_with_backend_and_env(&backend, &jail, command, &approved)?; + let mut output = String::new(); + child + .take_stdout() + .expect("captured stdout") + .read_to_string(&mut output)?; + let mut stderr = String::new(); + child + .take_stderr() + .expect("captured stderr") + .read_to_string(&mut stderr)?; + let status = wait_for_child(&mut child)?; + assert!( + output.contains("approved-value"), + "stdout={output:?}, stderr={stderr:?}, status={status:?}" + ); + if let Ok(parent_path) = std::env::var("PATH") { + assert!( + !output.contains(&parent_path), + "parent PATH leaked to the child" + ); + } + assert!(status.success()); + Ok(()) +} + +#[test] +fn appcontainer_acl_grants_the_root_and_denies_an_outside_secret() -> io::Result<()> { + use std::io::Read; + + let root = tempfile::tempdir()?; + let outside = tempfile::tempdir()?; + let allowed = root.path().join("allowed.txt"); + let secret = outside.path().join("secret.txt"); + std::fs::write(&allowed, "inside-marker")?; + std::fs::write(&secret, "outside-secret-marker")?; + + let command_text = format!("type {} & type {}", allowed.display(), secret.display()); + let mut command = Command::new(cmd_exe()); + command.args(["/C", &command_text]); + let jail = Jail::new(root.path(), "acl-regression").deny_net(); + let backend = AppContainerBackend::new(); + backend + .require(&jail) + .expect("filesystem preflight is enforced"); + let mut child = crate::spawn_owned_captured_with_backend_and_env( + &backend, + &jail, + command, + &BTreeMap::new(), + )?; + + let mut stdout = String::new(); + child + .take_stdout() + .expect("captured stdout") + .read_to_string(&mut stdout)?; + let mut stderr = String::new(); + child + .take_stderr() + .expect("captured stderr") + .read_to_string(&mut stderr)?; + let status = wait_for_child(&mut child)?; + assert!( + stdout.contains("inside-marker"), + "stdout={stdout:?}, stderr={stderr:?}, status={status:?}" + ); + assert!(!stdout.contains("outside-secret-marker")); + assert!(stderr.to_ascii_lowercase().contains("access is denied")); + assert!(!status.success(), "outside path access should fail"); + Ok(()) +} + +#[test] +fn windows_capabilities_report_acl_and_job_object_enforcement() { + use tinybox_core::{Constraint, Enforcement}; + + let support = windows_constraint_support(true); + assert_eq!( + support.enforcement(Constraint::Filesystem), + Enforcement::Enforced + ); + assert_eq!( + support.enforcement(Constraint::Network), + Enforcement::Enforced + ); + assert_eq!(support.enforcement(Constraint::Cpu), Enforcement::Enforced); + assert_eq!( + support.enforcement(Constraint::Memory), + Enforcement::Enforced + ); + assert_eq!(support.enforcement(Constraint::Pids), Enforcement::Enforced); + assert_eq!( + support.enforcement(Constraint::Disk), + Enforcement::Unsupported + ); +} + +#[test] +fn unsupported_windows_disk_limit_is_a_typed_preflight_refusal() { + use tinybox_core::{Constraint, Enforcement, Error}; + + let error = windows_constraint_support(true) + .require("appcontainer", &[Constraint::Disk]) + .expect_err("Job Objects do not enforce disk quotas"); + assert!(matches!( + error, + Error::ConstraintNotEnforced { + sandbox, + constraint: Constraint::Disk, + enforcement: Enforcement::Unsupported, + } if sandbox == "appcontainer" + )); +} + +#[test] +fn owned_backend_selection_uses_appcontainer_without_changing_legacy_selection() { + let backend = crate::detect::pick_owned_backend(); + assert_eq!(backend.name(), "appcontainer"); + assert!(!backend.is_available()); + assert!(backend.is_owned_available()); + assert_eq!( + backend + .constraint_support() + .enforcement(tinybox_core::Constraint::Memory), + tinybox_core::Enforcement::Enforced + ); +} + +#[test] +fn supported_job_object_requirements_pass_hard_preflight() { + use tinybox_core::Constraint; + + windows_constraint_support(true) + .require( + "appcontainer", + &[Constraint::Cpu, Constraint::Memory, Constraint::Pids], + ) + .expect("Job Objects enforce the selected hard limits"); +} + +#[test] +fn jail_preflight_accepts_supported_limits_and_refuses_hard_disk_limit() { + use tinybox_core::{Constraint, Enforcement}; + + let backend = AppContainerBackend::new(); + let supported = Jail::new(r"C:\work", "limits").with_hard_requirements([ + Constraint::Cpu, + Constraint::Memory, + Constraint::Pids, + ]); + assert!(backend.require_resources(&supported).is_ok()); + + let disk = Jail::new(r"C:\work", "limits").with_hard_requirements([Constraint::Disk]); + assert!(matches!( + backend.require_resources(&disk), + Err(tinybox_core::Error::ConstraintNotEnforced { + constraint: Constraint::Disk, + enforcement: Enforcement::Unsupported, + .. + }) + )); +} + +#[test] +fn box_spec_preflight_uses_only_explicit_hard_requirements() { + use tinybox_core::{Constraint, HostRef, Placement, SandboxRef, WorkspaceSource}; + + let placement = Placement::new( + HostRef::new("local").expect("valid host ref"), + SandboxRef::new("appcontainer").expect("valid sandbox ref"), + ); + let spec = tinybox_core::BoxSpec::new( + placement, + WorkspaceSource::LocalDir(PathBuf::from(r"C:\workspace")), + ); + let backend = AppContainerBackend::new(); + assert!(backend.plan_check_spec(&spec).constraints.is_empty()); + backend + .require_spec(&spec) + .expect("soft resource defaults do not become hard requirements"); + + let spec = spec.with_hard_requirements([Constraint::Cpu, Constraint::Memory, Constraint::Pids]); + let jail = Jail::new(r"C:\workspace", "spec-limits").with_box_spec(&spec); + assert!(backend.require_resources(&jail).is_ok()); + backend + .require_spec(&spec) + .expect("Job Objects enforce all listed resource requirements"); + + let disk_spec = spec.with_hard_requirements([Constraint::Disk]); + assert!(backend.require_spec(&disk_spec).is_err()); +} + +#[test] +fn owned_appcontainer_child_captures_output_waits_and_kills_on_drop() -> std::io::Result<()> { + use std::io::Read; + + let root = tempfile::tempdir()?; + let backend = AppContainerBackend::new(); + let mut echo = Command::new(cmd_exe()); + echo.args(["/C", "echo owned-child-ok"]); + let jail = Jail::new(root.path(), "owned-child-capture").deny_net(); + let mut child = crate::spawn_owned_captured_with(&backend, &jail, echo)?; + let mut output = String::new(); + child + .take_stdout() + .expect("captured stdout") + .read_to_string(&mut output)?; + let mut stderr = String::new(); + child + .take_stderr() + .expect("captured stderr") + .read_to_string(&mut stderr)?; + let status = wait_for_child(&mut child)?; + assert!( + output.contains("owned-child-ok"), + "stdout={output:?}, stderr={stderr:?}, status={status:?}" + ); + assert!(status.success(), "captured child exits successfully"); + + let mut waiting = Command::new(cmd_exe()); + waiting.args(["/C", "set /p waiting="]); + let jail = Jail::new(root.path(), "owned-child-terminate").deny_net(); + let mut child = crate::spawn_owned_captured_with(&backend, &jail, waiting)?; + let _keep_stdin_open = child.take_stdin(); + let mut stdout = child.take_stdout().expect("captured stdout"); + child.terminate()?; + let mut remaining = Vec::new(); + stdout.read_to_end(&mut remaining)?; + assert!( + wait_for_child(&mut child).is_ok(), + "terminated job exits after termination is acknowledged" + ); + + let mut waiting = Command::new(cmd_exe()); + waiting.args(["/C", "set /p waiting="]); + let jail = Jail::new(root.path(), "owned-child-cleanup").deny_net(); + let mut child = crate::spawn_owned_captured_with(&backend, &jail, waiting)?; + let _keep_stdin_open = child.take_stdin(); + let mut stdout = child.take_stdout().expect("captured stdout"); + drop(child); + let mut remaining = Vec::new(); + stdout.read_to_end(&mut remaining)?; + Ok(()) +} + +fn wait_for_child(child: &mut crate::OwnedChild) -> std::io::Result { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if std::time::Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "owned child did not exit after captured output closed", + )); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} diff --git a/crates/tinybox-linux/src/lib.rs b/crates/tinybox-linux/src/lib.rs index c00047d8..74ffe242 100644 --- a/crates/tinybox-linux/src/lib.rs +++ b/crates/tinybox-linux/src/lib.rs @@ -32,7 +32,8 @@ //! way an unprivileged process can create a user namespace at all, because //! `kernel.apparmor_restrict_unprivileged_userns` blocks unconfined binaries //! from doing it. ADR 0005 records the decision; the workspace keeps -//! `unsafe_code = "forbid"` everywhere, with no exception. +//! `unsafe_code = "deny"` by default across the workspace. The single Windows +//! FFI exception is scoped to `tinybox-jail/src/windows.rs` and checked in CI. //! //! # What this backend does not do //! diff --git a/crates/tinybox-linux/src/sandbox/args.rs b/crates/tinybox-linux/src/sandbox/args.rs index 52f577cb..b0af54bf 100644 --- a/crates/tinybox-linux/src/sandbox/args.rs +++ b/crates/tinybox-linux/src/sandbox/args.rs @@ -9,7 +9,10 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use tinybox_core::{BoxSpec, Capability, Error, ExecRequest, Resources, Result, WorkspaceSource}; +use tinybox_core::{ + BoxSpec, Capability, Constraint, ConstraintSupport, Enforcement, Error, ExecRequest, Resources, + Result, WorkspaceSource, +}; use super::NAME; @@ -26,6 +29,44 @@ const USR: &str = "/usr"; const SYSTEMD_EXPAND_ENVIRONMENT_FLAG: &[u8] = b"--expand-environment"; static NATIVE_SCOPE_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ResourceEnforcement { + CgroupV2, + Setrlimit, + Unavailable, +} + +pub(super) const fn select_resource_enforcement( + cgroup_v2_delegated: bool, + allow_setrlimit_fallback: bool, +) -> ResourceEnforcement { + if cgroup_v2_delegated { + ResourceEnforcement::CgroupV2 + } else if allow_setrlimit_fallback { + ResourceEnforcement::Setrlimit + } else { + ResourceEnforcement::Unavailable + } +} + +pub(super) const fn resource_constraint_support( + enforcement: ResourceEnforcement, +) -> ConstraintSupport { + match enforcement { + ResourceEnforcement::CgroupV2 => ConstraintSupport::NONE + .with(Constraint::Cpu, Enforcement::Enforced) + .with(Constraint::Memory, Enforcement::Enforced) + .with(Constraint::Pids, Enforcement::Enforced), + // RLIMIT_AS gives each process in the box a hard address-space ceiling, + // but the box's processes together can exceed it, so memory is only + // best effort. CPU rate and process count have no equivalent at all. + ResourceEnforcement::Setrlimit => { + ConstraintSupport::NONE.with(Constraint::Memory, Enforcement::BestEffort) + } + ResourceEnforcement::Unavailable => ConstraintSupport::NONE, + } +} + /// Files a normal command expects to exist. /// /// Deliberately a short list rather than `--ro-bind /etc /etc`. Name resolution @@ -80,6 +121,62 @@ pub(super) fn exec( spec: &BoxSpec, request: &ExecRequest, limits: bool, + setrlimit_fallback: bool, + supports_expand_environment: bool, +) -> Result> { + exec_with_prlimit_resolver( + spec, + request, + limits, + setrlimit_fallback, + supports_expand_environment, + trusted_prlimit_program, + ) +} + +pub(super) fn exec_with_prlimit_resolver( + spec: &BoxSpec, + request: &ExecRequest, + limits: bool, + setrlimit_fallback: bool, + supports_expand_environment: bool, + resolve_prlimit: impl FnOnce() -> std::io::Result, +) -> Result> { + let prlimit = fallback_prlimit(limits, setrlimit_fallback, resolve_prlimit)?; + exec_with_prlimit_program( + spec, + request, + limits, + prlimit.as_deref(), + supports_expand_environment, + ) +} + +/// The trusted `prlimit` the setrlimit fallback launches through, when that +/// fallback is in use. +/// +/// # Errors +/// Returns [`Error::Unsupported`] for resource limits when no trusted +/// `prlimit` exists. +pub(super) fn fallback_prlimit( + limits: bool, + setrlimit_fallback: bool, + resolve_prlimit: impl FnOnce() -> std::io::Result, +) -> Result> { + if limits || !setrlimit_fallback { + return Ok(None); + } + resolve_prlimit().map(Some).map_err(|_| Error::Unsupported { + sandbox: NAME.to_owned(), + capability: Capability::ResourceLimits, + }) +} + +pub(super) fn exec_with_prlimit_program( + spec: &BoxSpec, + request: &ExecRequest, + limits: bool, + prlimit_program: Option<&Path>, supports_expand_environment: bool, ) -> Result> { if request.argv.is_empty() { @@ -94,9 +191,41 @@ pub(super) fn exec( Vec::new() }; argv.extend(bwrap(spec, request)?); + if !limits && let Some(prlimit) = prlimit_program { + wrap_setrlimit(&mut argv, &spec.resources, prlimit); + } Ok(argv) } +pub(super) fn wrap_setrlimit(argv: &mut Vec, resources: &Resources, prlimit: &Path) { + argv.splice( + 0..0, + [ + prlimit.to_string_lossy().into_owned(), + format!("--as={}", resources.memory_bytes), + "--".to_owned(), + ], + ); +} + +pub(super) fn trusted_prlimit_program() -> std::io::Result { + trusted_program_from([Path::new("/usr/bin/prlimit"), Path::new("/bin/prlimit")]) +} + +pub(super) fn trusted_program_from<'a>( + paths: impl IntoIterator, +) -> std::io::Result { + paths + .into_iter() + .find_map(|path| validate_trusted_program(path).ok()) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "no trusted executable in fixed system locations", + ) + }) +} + /// Wrap the sandbox in a transient systemd scope so cgroup limits apply. /// /// Rootless cgroup v2 limits go through the user's own systemd session, which @@ -237,9 +366,51 @@ pub fn native_jail_argv_with_programs( let bwrap = validate_trusted_program(bwrap)?; let supports_expand_environment = systemd_run_supports_safe_arguments(&systemd_run)?; let argv = native_bwrap_argv(request, supports_expand_environment, &systemd_run, &bwrap); - Ok(native_launcher_argv(argv)) + Ok(native_launcher_argv(wrap_scope_command(argv))) } +/// Sample cgroup counters from inside the transient scope before it is stopped. +fn wrap_scope_command(mut argv: Vec) -> Vec { + let Some(delimiter) = argv.iter().position(|arg| arg == "--") else { + return argv; + }; + let Some(unit) = argv + .iter() + .find_map(|arg| arg.strip_prefix("--unit=").map(str::to_owned)) + else { + return argv; + }; + let workload = argv.split_off(delimiter + 1); + argv.extend([ + "/bin/bash".to_owned(), + "--noprofile".to_owned(), + "--norc".to_owned(), + "-c".to_owned(), + CPU_EVENT_SCOPE_WRAPPER.to_owned(), + "tinybox-cpu-event-scope".to_owned(), + unit, + ]); + argv.extend(workload); + argv +} + +const CPU_EVENT_SCOPE_WRAPPER: &str = r#" +unit=$1 +shift +"$@" +status=$? +report=${TINYBOX_INTERNAL_CPU_EVENT_FILE:-} +if [ -n "$report" ]; then + control_group=$(/usr/bin/systemctl --user show --property=ControlGroup --value "$unit" 2>/dev/null) + stats= + case $control_group in /*) stats=/sys/fs/cgroup${control_group}/cpu.stat;; esac + if [ -r "$stats" ]; then + { stat=$(<"$stats") && printf '%s\n' "$stat" > "$report"; } 2>/dev/null || : + fi +fi +exit "$status" +"#; + /// Find systemd-run only in fixed system locations and return its trusted /// canonical path. This does not consult the caller's `PATH`. /// @@ -545,7 +716,7 @@ fn ensure_mount_parents(argv: &mut Vec, request: &NativeJailCommand) { } /// The `bwrap` invocation itself. -fn bwrap(spec: &BoxSpec, request: &ExecRequest) -> Result> { +pub(super) fn bwrap(spec: &BoxSpec, request: &ExecRequest) -> Result> { let workspace = workspace_dir(spec)?; let mut argv = vec!["bwrap".to_owned()]; diff --git a/crates/tinybox-linux/src/sandbox/mod.rs b/crates/tinybox-linux/src/sandbox/mod.rs index 00259110..01f63bab 100644 --- a/crates/tinybox-linux/src/sandbox/mod.rs +++ b/crates/tinybox-linux/src/sandbox/mod.rs @@ -49,6 +49,7 @@ pub struct NamespaceSandbox { store: Arc, clock: Arc, limits: bool, + setrlimit_fallback: bool, } impl NamespaceSandbox { @@ -64,6 +65,7 @@ impl NamespaceSandbox { store, clock: Arc::new(SystemClock::new()), limits: false, + setrlimit_fallback: false, } } @@ -80,6 +82,16 @@ impl NamespaceSandbox { self } + /// Use `RLIMIT_AS` for a per-process memory ceiling when cgroup v2 is not + /// selected. Memory is best effort, since processes together can exceed + /// it; CPU quota and per-box PID limits remain unsupported because + /// `setrlimit` has no equivalent aggregate controls. + #[must_use] + pub fn with_setrlimit_fallback(mut self) -> Self { + self.setrlimit_fallback = true; + self + } + /// Read creation times from `clock` rather than the operating system. #[must_use] pub fn with_clock(mut self, clock: Arc) -> Self { @@ -112,28 +124,31 @@ impl Sandbox for NamespaceSandbox { fn constraint_support(&self) -> tinybox_core::ConstraintSupport { use tinybox_core::Constraint::{Cpu, Filesystem, Memory, Network, Pids}; use tinybox_core::Enforcement::Enforced; - if self.limits { - tinybox_core::ConstraintSupport::NONE - .with(Filesystem, Enforced) - .with(Network, Enforced) - .with(Cpu, Enforced) - .with(Memory, Enforced) - .with(Pids, Enforced) - } else { - tinybox_core::ConstraintSupport::NONE - .with(Filesystem, Enforced) - .with(Network, Enforced) - } + let resource_enforcement = + args::select_resource_enforcement(self.limits, self.setrlimit_fallback); + let resource_support = args::resource_constraint_support(resource_enforcement); + tinybox_core::ConstraintSupport::NONE + .with(Filesystem, Enforced) + .with(Network, Enforced) + .with(Cpu, resource_support.enforcement(Cpu)) + .with(Memory, resource_support.enforcement(Memory)) + .with(Pids, resource_support.enforcement(Pids)) } async fn create(&self, spec: &BoxSpec) -> Result { spec.validate()?; + self.require_spec(spec)?; spec.network.refuse_allowlist(self.name())?; // Nothing is started here: a box is a record plus a directory, and the // sandbox itself exists only for the length of a command. Checking the // source now means an unusable spec fails at `create` rather than // surprising the caller at the first `exec`. args::workspace_dir(spec)?; + args::fallback_prlimit( + self.limits, + self.setrlimit_fallback, + args::trusted_prlimit_program, + )?; tinybox_core::insert_new(self.store.as_ref(), BoxState::Ready, spec, self.clock.now()) } @@ -161,6 +176,7 @@ impl Sandbox for NamespaceSandbox { &info.spec, request, self.limits, + self.setrlimit_fallback, supports_expand_environment, )?; // A command that runs and fails is a result, so the raw output is diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index b3bb18af..5655327b 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -9,7 +9,6 @@ use std::collections::BTreeMap; #[cfg(target_os = "linux")] use std::os::unix::fs::PermissionsExt as _; -#[cfg(target_os = "linux")] use std::path::Path; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; @@ -474,12 +473,6 @@ fn native_jail_command_keeps_limits_and_target_environment_after_namespace_setup assert_eq!(argv.first().map(String::as_str), Some("/bin/bash")); assert!(argv.contains(&systemd_run.display().to_string())); assert!(systemd_run.is_absolute()); - let bubblewrap = argv - .iter() - .position(|part| part == "--") - .and_then(|position| argv.get(position + 1)) - .ok_or("missing bubblewrap command")?; - assert!(std::path::Path::new(bubblewrap).is_absolute()); assert!(has(&argv, "--property=CPUQuota=0.1%")); assert!(has(&argv, "--property=MemoryMax=67108864")); assert!(has(&argv, "--property=MemorySwapMax=0")); @@ -489,6 +482,15 @@ fn native_jail_command_keeps_limits_and_target_environment_after_namespace_setup assert!(has(&argv, "--tmpfs")); assert!(has(&argv, "/run")); assert!(has(&argv, "/sys")); + assert!( + argv.iter() + .any(|part| part.contains("TINYBOX_INTERNAL_CPU_EVENT_FILE")) + ); + assert!(argv.iter().any(|part| part.contains("cpu.stat"))); + assert!( + !argv.iter().any(|part| part.contains("cat \"")), + "the CPU report reads its stats with a shell builtin, not PATH" + ); assert!(has(&argv, "--ro-bind")); assert!(has(&argv, grant.to_str().ok_or("grant path is not UTF-8")?)); assert!(has(&argv, "BASH_ENV")); @@ -845,3 +847,209 @@ async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<( )); Ok(()) } + +#[tokio::test] +async fn explicitly_required_cpu_is_refused_before_box_storage() -> Result<()> { + use tinybox_core::{Constraint, Enforcement}; + + let store = Arc::new(MemoryStore::new()); + let sandbox = NamespaceSandbox::new(Arc::new(RecordingHost::default()), store.clone()); + let requested = spec()?.with_hard_requirements([Constraint::Cpu]); + + assert_eq!( + sandbox.plan_check_spec(&requested).constraints, + vec![(Constraint::Cpu, Enforcement::Unsupported)] + ); + assert!(matches!( + sandbox.create(&requested).await, + Err(Error::ConstraintNotEnforced { + constraint: Constraint::Cpu, + enforcement: Enforcement::Unsupported, + .. + }) + )); + assert_eq!(store.list()?, []); + Ok(()) +} + +#[tokio::test] +async fn explicitly_required_supported_constraints_allow_creation() -> Result<()> { + use tinybox_core::Constraint; + + let store = Arc::new(MemoryStore::new()); + let sandbox = NamespaceSandbox::new(Arc::new(RecordingHost::default()), store.clone()); + let requested = spec()?.with_hard_requirements([Constraint::Filesystem, Constraint::Network]); + + sandbox.require_spec(&requested)?; + assert_eq!(sandbox.create(&requested).await?.state, BoxState::Ready); + assert_eq!(store.list()?.len(), 1); + Ok(()) +} + +#[test] +fn resource_enforcement_selection_uses_cgroups_then_setrlimit_fallback() { + use tinybox_core::{Constraint, Enforcement}; + + assert_eq!( + args::select_resource_enforcement(true, true), + args::ResourceEnforcement::CgroupV2 + ); + assert_eq!( + args::select_resource_enforcement(false, true), + args::ResourceEnforcement::Setrlimit + ); + assert_eq!( + args::select_resource_enforcement(false, false), + args::ResourceEnforcement::Unavailable + ); + + let cgroup = args::resource_constraint_support(args::ResourceEnforcement::CgroupV2); + let fallback = args::resource_constraint_support(args::ResourceEnforcement::Setrlimit); + let unavailable = args::resource_constraint_support(args::ResourceEnforcement::Unavailable); + for constraint in [Constraint::Cpu, Constraint::Memory, Constraint::Pids] { + assert_eq!(cgroup.enforcement(constraint), Enforcement::Enforced); + } + assert_eq!( + fallback.enforcement(Constraint::Memory), + Enforcement::BestEffort + ); + for constraint in [Constraint::Cpu, Constraint::Pids] { + assert_eq!(fallback.enforcement(constraint), Enforcement::Unsupported); + assert_eq!( + unavailable.enforcement(constraint), + Enforcement::Unsupported + ); + } + assert_eq!( + unavailable.enforcement(Constraint::Memory), + Enforcement::Unsupported + ); +} + +#[tokio::test] +async fn setrlimit_fallback_refuses_resource_hard_requirements() -> Result<()> { + use tinybox_core::Constraint; + + let sandbox = NamespaceSandbox::new( + Arc::new(RecordingHost::default()), + Arc::new(MemoryStore::new()), + ) + .with_setrlimit_fallback(); + + // A per-process address-space ceiling is not a cap on the whole box. + for constraint in [Constraint::Memory, Constraint::Cpu, Constraint::Pids] { + let requested = spec()?.with_hard_requirements([constraint]); + assert!(matches!( + sandbox.create(&requested).await, + Err(Error::ConstraintNotEnforced { constraint: unsupported, .. }) if unsupported == constraint + )); + } + Ok(()) +} + +#[tokio::test] +async fn setrlimit_fallback_creates_a_box_only_with_a_trusted_prlimit() -> Result<()> { + let sandbox = NamespaceSandbox::new( + Arc::new(RecordingHost::default()), + Arc::new(MemoryStore::new()), + ) + .with_setrlimit_fallback(); + let created = sandbox.create(&spec()?).await; + if args::trusted_prlimit_program().is_ok() { + assert_eq!(created?.state, BoxState::Ready); + } else { + assert!(matches!( + created, + Err(Error::Unsupported { + capability: tinybox_core::Capability::ResourceLimits, + .. + }) + )); + } + Ok(()) +} + +#[test] +fn create_and_exec_need_a_trusted_prlimit_only_under_the_fallback() -> Result<()> { + let missing = || Err(std::io::Error::from(std::io::ErrorKind::NotFound)); + assert!(args::fallback_prlimit(true, true, missing)?.is_none()); + assert!(args::fallback_prlimit(false, false, missing)?.is_none()); + assert!(matches!( + args::fallback_prlimit(false, true, missing), + Err(Error::Unsupported { + capability: Capability::ResourceLimits, + .. + }) + )); + let trusted = || Ok(Path::new("/trusted/prlimit").to_path_buf()); + assert_eq!( + args::fallback_prlimit(false, true, trusted)?.as_deref(), + Some(Path::new("/trusted/prlimit")) + ); + Ok(()) +} + +#[test] +fn setrlimit_fallback_wraps_commands_with_address_space_limit() -> Result<()> { + let spec = spec()?.with_resources(Resources { + cpu_millis: 1_000, + memory_bytes: 64 * 1024 * 1024, + pids_max: 16, + disk_bytes: 1, + }); + let argv = args::exec_with_prlimit_resolver( + &spec, + &ExecRequest::new(["true"]), + false, + true, + false, + || Ok(Path::new("/trusted/prlimit").to_path_buf()), + )?; + assert!( + argv.first() + .is_some_and(|program| program.ends_with("/prlimit")) + ); + assert!(has(&argv, "--as=67108864")); + assert!(has(&argv, "--")); + assert!(has(&argv, "bwrap")); + Ok(()) +} + +#[test] +fn setrlimit_fallback_refuses_when_no_trusted_launcher_is_available() -> Result<()> { + let spec = spec()?; + assert!(matches!( + args::exec_with_prlimit_resolver( + &spec, + &ExecRequest::new(["true"]), + false, + true, + false, + || Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "missing prlimit", + )), + ), + Err(Error::Unsupported { + capability: Capability::ResourceLimits, + .. + }) + )); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn trusted_resource_launcher_resolution_accepts_only_fixed_trusted_paths() -> std::io::Result<()> { + let program = args::trusted_program_from([Path::new("/usr/bin/true")])?; + assert!(program.is_absolute()); + assert_eq!( + args::trusted_program_from([Path::new("/path/that/does/not/exist")]) + .err() + .map(|error| error.kind()), + Some(std::io::ErrorKind::NotFound) + ); + #[cfg(target_os = "linux")] + assert!(args::trusted_prlimit_program()?.is_absolute()); + Ok(()) +} diff --git a/crates/tinybox-microvm/src/sandbox/mod.rs b/crates/tinybox-microvm/src/sandbox/mod.rs index 5da7fe34..7f2024fa 100644 --- a/crates/tinybox-microvm/src/sandbox/mod.rs +++ b/crates/tinybox-microvm/src/sandbox/mod.rs @@ -169,6 +169,7 @@ impl Sandbox for MicroVmSandbox { async fn create(&self, spec: &BoxSpec) -> Result { spec.validate()?; + self.require_spec(spec)?; spec.network.refuse_allowlist(self.name())?; // Nothing boots here: a box is a record and a directory, and the VM // exists only for the length of a command. Checking the source now diff --git a/crates/tinybox-microvm/src/sandbox/mod_tests.rs b/crates/tinybox-microvm/src/sandbox/mod_tests.rs index c5c9d7d6..a34eb3e1 100644 --- a/crates/tinybox-microvm/src/sandbox/mod_tests.rs +++ b/crates/tinybox-microvm/src/sandbox/mod_tests.rs @@ -658,6 +658,27 @@ fn preflight_reports_each_enforced_boundary_without_claiming_disk() { assert!(backend.require(&[Constraint::Disk]).is_err()); } +#[test] +fn spec_hard_requirements_reject_best_effort_cpu() -> Result<()> { + use tinybox_core::{Constraint, Enforcement}; + + let backend = sandbox(ScriptedHost::booting("", 0)); + let requested = spec()?.with_hard_requirements([Constraint::Cpu]); + assert_eq!( + backend.plan_check_spec(&requested).constraints, + vec![(Constraint::Cpu, Enforcement::BestEffort)] + ); + assert!(matches!( + backend.require_spec(&requested), + Err(Error::ConstraintNotEnforced { + constraint: Constraint::Cpu, + enforcement: Enforcement::BestEffort, + .. + }) + )); + Ok(()) +} + #[tokio::test] async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<()> { let sandbox = sandbox(ScriptedHost::booting("", 0)); diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index 2bcde7a1..aa2def9e 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -55,7 +55,7 @@ The interface is `ai.tinyhumans.tinybox.Box` at | KillStdio | ProcessRef | () | | StdioStatus | ProcessRef | StdioStatus | -Capabilities advertises contract version 2.0. Hosts require equal majors and a +Capabilities advertises contract version 2.2. Hosts require equal majors and a module minor at least as new as their vocabulary, using `tinybox_bus::is_compatible`. Contract 1.0 was the original discovery-only surface; 1.1 added reserved resource/process ownership and terminal shutdown; @@ -76,6 +76,19 @@ execution policy. Old 1.x hosts must reject a 2.x module; there is no fallback that applies or reconstructs command policy in TinyBox. Describe remains unchanged. +Contract 2.1 adds resource-aware native jail request members. Contract 2.2 adds +the `ProcessEvent` signal, carrying the caller's `ProcessRef` with each +observation. The module emits `Started` once after a successful `StartExec`, +`StartJailedExec`, `StartJailedExecWithResources`, `StartShellExec`, or +`StartNativeShellExec`, then `Exited` after its supervisor observes the +terminal exit status. When an execution times out, it emits +`LimitHit { limit: Timeout }` before `Exited { status: None }`. Replayed starts +do not duplicate these events. Signal delivery is best-effort; this increment +also emits `LimitHit { limit: Cpu }` before exit when the trusted Linux +cgroup-v2 CPU counter proves that a scoped CPU quota throttled the workload. +This reports throttling, not CPU exhaustion or process termination. Other +resource-limit observations remain unpublished. + Version 1.5 adds bounded file transfer for mounted directory workspaces. Reserve `FileRead(resource)` or `FileWrite(resource)` before opening a transfer. Paths are relative to the workspace and reject traversal; each resource holds diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index 5bc75249..faf71c31 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -12,8 +12,9 @@ use tinybox_bus::{ AbortFileWriteRequest, BeginFileReadRequest, BeginFileWriteRequest, CloseForwardRequest, CreateRequest, ExecOutput, ExecRequest, FileChunk, FileReadInfo, FileWriteInfo, FileWriteProgress, FinishFileReadRequest, FinishFileWriteRequest, ForwardInfo, ForwardRequest, - INTERFACE, OBJECT_PATH, ProcessRef, ReadFileChunkRequest, ResourceId, ResourceInfo, - ShellAnalysis, SpawnRequest, WriteFileChunkRequest, + INTERFACE, OBJECT_PATH, ProcessEvent, ProcessRef, ReadFileChunkRequest, ResourceId, + ResourceInfo, RunNativeShellRequest, RunNativeShellResult, SandboxEvent, ShellAnalysis, + SpawnRequest, WriteFileChunkRequest, }; mod docker; @@ -50,6 +51,7 @@ impl Platform { #[derive(Default)] struct BoxService { resources: std::sync::Arc, + connection: Option, } impl BoxService { @@ -57,9 +59,46 @@ impl BoxService { /// `connection`. fn connected(connection: Connection) -> Self { Self { - resources: std::sync::Arc::new(resources::Resources::with_connection(connection)), + resources: std::sync::Arc::new(resources::Resources::with_connection( + connection.clone(), + )), + connection: Some(connection), } } + + async fn publish_process_events(&self, process: &ProcessRef) { + let Some(connection) = self.connection.clone() else { + return; + }; + let Ok(Some(stream)) = self.resources.stream(process) else { + return; + }; + if !stream.claim_event_publisher() { + return; + } + emit_process_event( + &connection, + ProcessEvent { + process: process.clone(), + event: SandboxEvent::Started, + }, + ) + .await; + let event_process = process.clone(); + tokio::spawn(async move { + let status = stream.wait_for_terminal_exit().await; + for event in stream.terminal_events(status) { + emit_process_event( + &connection, + ProcessEvent { + process: event_process.clone(), + event, + }, + ) + .await; + } + }); + } } /// Serialize resolved grant paths without replacing invalid UTF-8 bytes. @@ -261,10 +300,12 @@ impl BoxService { /// Start module-owned execution with replayable bounded output. async fn start_exec(&self, request: SpawnRequest) -> TinyBusResult { let resources = self.resources.clone(); - finish_operation(tokio::spawn( + let process = finish_operation(tokio::spawn( async move { resources.start_exec(request).await }, )) - .await + .await?; + self.publish_process_events(&process).await; + Ok(process) } /// Start an execution in the resource's native directory jail. async fn start_jailed_exec( @@ -273,10 +314,12 @@ impl BoxService { config: tinybox_bus::NativeJailConfig, ) -> TinyBusResult { let resources = self.resources.clone(); - finish_operation(tokio::spawn(async move { + let process = finish_operation(tokio::spawn(async move { resources.start_jailed_exec(request, config).await })) - .await + .await?; + self.publish_process_events(&process).await; + Ok(process) } /// Start a jailed execution with the separately negotiated contract 2.1 @@ -288,12 +331,14 @@ impl BoxService { config: tinybox_bus::NativeJailRequest, ) -> TinyBusResult { let resources = self.resources.clone(); - finish_operation(tokio::spawn(async move { + let process = finish_operation(tokio::spawn(async move { resources .start_jailed_exec_with_resources(request, config) .await })) - .await + .await?; + self.publish_process_events(&process).await; + Ok(process) } /// Execute platform shell text using the original two-argument contract. @@ -303,10 +348,12 @@ impl BoxService { shell: tinybox_bus::SelectShellRequest, ) -> TinyBusResult { let resources = self.resources.clone(); - finish_operation(tokio::spawn(async move { + let process = finish_operation(tokio::spawn(async move { resources.start_platform_shell_exec(request, shell).await })) - .await + .await?; + self.publish_process_events(&process).await; + Ok(process) } /// Start a shell with module-owned environment, scratch and supervision. @@ -315,10 +362,32 @@ impl BoxService { request: tinybox_bus::ShellExecRequest, ) -> TinyBusResult { let resources = self.resources.clone(); - finish_operation(tokio::spawn(async move { + let process = finish_operation(tokio::spawn(async move { resources.start_shell_exec(request).await })) - .await + .await?; + self.publish_process_events(&process).await; + Ok(process) + } + + /// Supervise a native shell through completion or the requested yield + /// point. Dropping the bus invocation requests cancellation and cleanup in + /// the module-owned worker. + async fn run_native_shell( + &self, + request: RunNativeShellRequest, + ) -> TinyBusResult { + let resources = self.resources.clone(); + let (cancel, cancelled) = tokio::sync::oneshot::channel::(); + let task = tokio::spawn(async move { + native_shell::run::run(resources, request, async move { + let _ = cancelled.await; + }) + .await + }); + let result = finish_operation(task).await; + drop(cancel); + result } /// Start one program with piped stdio inside the resource's native jail. @@ -462,6 +531,17 @@ impl BoxService { } } +async fn emit_process_event(connection: &Connection, event: ProcessEvent) { + let (Ok(path), Ok(interface), Ok(member)) = ( + OBJECT_PATH.try_into(), + INTERFACE.try_into(), + tinybox_bus::names::PROCESS_EVENT.try_into(), + ) else { + return; + }; + let _ = connection.emit(path, interface, member, event).await; +} + fn capabilities_for(platform: Platform) -> tinybox_bus::ModuleCapabilities { let supervised: Vec = if matches!( platform, @@ -609,8 +689,8 @@ tinybus_module::module_export_optional_static! { setup = setup, worker_threads = 1, provides = ["ai.tinyhumans.tinybox.Box"], - methods = ["Describe", "Create", "Exec", "Inspect", "Close", "Spawn", "IsRunning", "Cancel", "AnalyzeShell", "Reserve", "Capabilities", "Shutdown", "Forward", "CloseForward", "JailStatus", "BeginFileRead", "ReadFileChunk", "FinishFileRead", "BeginFileWrite", "WriteFileChunk", "FinishFileWrite", "AbortFileWrite", "StartExec", "ReadOutput", "ReleaseOutput", "StartJailedExec", "RunDocker", "DockerStatus", "CleanupDocker", "RunDockerWithoutWorkspace", "ResolveLocalGrants", "SelectShell", "StartShellExec", "StartNativeShellExec", "ValidateDockerPolicy", "StartStdio", "WriteStdio", "ReadStdio", "CloseStdin", "KillStdio", "StdioStatus", "StartJailedExecWithResources"], - signals = [], + methods = ["Describe", "Create", "Exec", "Inspect", "Close", "Spawn", "IsRunning", "Cancel", "AnalyzeShell", "Reserve", "Capabilities", "Shutdown", "Forward", "CloseForward", "JailStatus", "BeginFileRead", "ReadFileChunk", "FinishFileRead", "BeginFileWrite", "WriteFileChunk", "FinishFileWrite", "AbortFileWrite", "StartExec", "ReadOutput", "ReleaseOutput", "StartJailedExec", "RunDocker", "DockerStatus", "CleanupDocker", "RunDockerWithoutWorkspace", "ResolveLocalGrants", "SelectShell", "StartShellExec", "StartNativeShellExec", "ValidateDockerPolicy", "StartStdio", "WriteStdio", "ReadStdio", "CloseStdin", "KillStdio", "StdioStatus", "StartJailedExecWithResources", "RunNativeShell"], + signals = ["ProcessEvent"], requires = [], optional = [], lazy = false, diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 137c72f1..23aea3b1 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -925,6 +925,185 @@ async fn bounded_live_output_roundtrips_through_the_bus_codec() -> tinybus::Resu Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn start_exec_broadcasts_correlated_process_lifecycle_events() -> tinybus::Result<()> { + use serde_json::Value; + use tinybox_bus::{ + CreateRequest, ExecRequest, ProcessRef, ReserveRequest, ResourceId, SpawnRequest, Workspace, + }; + + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let _storage_host = serve_test_storage(&bus).await?; + let service = Connection::connect(bus.connect().await?).await?; + setup(service).await?; + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; + let mut events = proxy.receive_signal("ProcessEvent").await?; + let resource: ResourceId = proxy.call("Reserve", (ReserveRequest::Resource,)).await?; + proxy + .call::( + "Create", + (CreateRequest { + resource: resource.clone(), + backend: "passthrough".into(), + workspace: Workspace::Directory(".".into()), + ..Default::default() + },), + ) + .await?; + let process_id: ResourceId = proxy + .call("Reserve", (ReserveRequest::Process(resource.clone()),)) + .await?; + let process = ProcessRef { + resource: resource.clone(), + process: process_id.clone(), + }; + #[cfg(windows)] + let argv = vec!["cmd".into(), "/C".into(), "exit 7".into()]; + #[cfg(not(windows))] + let argv = vec!["/bin/sh".into(), "-c".into(), "exit 7".into()]; + let request = SpawnRequest { + process: process_id.clone(), + command: ExecRequest { + resource: resource.clone(), + argv, + cwd: None, + env: std::collections::BTreeMap::new(), + stdin: None, + }, + }; + assert_eq!( + proxy + .call::("StartExec", (request.clone(),)) + .await?, + process + ); + assert_eq!( + proxy.call::("StartExec", (request,)).await?, + process + ); + + let receive_event = async |events: &mut tokio::sync::broadcast::Receiver| { + tokio::time::timeout(std::time::Duration::from_secs(2), events.recv()) + .await + .map_err(|_| tinybus::Error::failed("timed out waiting for process event"))? + .map_err(|error| tinybus::Error::failed(error.to_string())) + }; + let started = receive_event(&mut events).await?; + let started: Value = serde_json::from_value(started.body[0].clone())?; + assert_eq!(started["process"]["resource"], resource.0); + assert_eq!(started["process"]["process"], process_id.0); + assert_eq!(started["event"]["event"], "started"); + + let exited = receive_event(&mut events).await?; + let exited: Value = serde_json::from_value(exited.body[0].clone())?; + assert_eq!(exited["process"]["resource"], resource.0); + assert_eq!(exited["process"]["process"], process.process.0); + assert_eq!(exited["event"]["event"], "exited"); + assert_eq!(exited["event"]["status"], 7); + assert!(matches!( + events.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); + proxy.call::<()>("Close", (resource,)).await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn native_shell_timeout_broadcasts_limit_hit_before_terminal_exit() -> tinybus::Result<()> { + use serde_json::Value; + use tinybox_bus::{ + CreateRequest, ProcessRef, ReserveRequest, ResourceId, SelectShellRequest, + ShellExecRequest, Workspace, + }; + + let state = tempfile::tempdir().map_err(tinybus::Error::failed)?; + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let _storage_host = serve_test_storage(&bus).await?; + let service = Connection::connect(bus.connect().await?).await?; + setup(service).await?; + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; + let mut events = proxy.receive_signal("ProcessEvent").await?; + let resource: ResourceId = proxy.call("Reserve", (ReserveRequest::Resource,)).await?; + proxy + .call::( + "Create", + (CreateRequest { + resource: resource.clone(), + backend: "passthrough".into(), + workspace: Workspace::Directory(".".into()), + ..Default::default() + },), + ) + .await?; + let process_id: ResourceId = proxy + .call("Reserve", (ReserveRequest::Process(resource.clone()),)) + .await?; + let process = ProcessRef { + resource: resource.clone(), + process: process_id.clone(), + }; + #[cfg(windows)] + let command = + "echo partial & powershell -NoProfile -NonInteractive -Command \"Start-Sleep -Seconds 60\""; + #[cfg(not(windows))] + let command = "echo partial; sleep 60"; + let request = ShellExecRequest { + process: process_id.clone(), + resource: resource.clone(), + shell: SelectShellRequest { + command: command.into(), + preserve_pipeline_status: false, + runtime_path: None, + python_utf8: false, + }, + cwd: None, + extra_env: std::collections::BTreeMap::new(), + state_dir: state.path().to_string_lossy().into_owned(), + timeout_ms: 1_000, + stdin: None, + jail: None, + hide_window: false, + }; + assert_eq!( + proxy + .call::("StartNativeShellExec", (request,)) + .await?, + process + ); + + let receive_event = async |events: &mut tokio::sync::broadcast::Receiver| { + tokio::time::timeout(std::time::Duration::from_secs(5), events.recv()) + .await + .map_err(|_| tinybus::Error::failed("timed out waiting for process event"))? + .map_err(|error| tinybus::Error::failed(error.to_string())) + }; + let started = receive_event(&mut events).await?; + let started: Value = serde_json::from_value(started.body[0].clone())?; + assert_eq!(started["process"]["resource"], resource.0); + assert_eq!(started["process"]["process"], process_id.0); + assert_eq!(started["event"]["event"], "started"); + + let limit_hit = receive_event(&mut events).await?; + let limit_hit: Value = serde_json::from_value(limit_hit.body[0].clone())?; + assert_eq!(limit_hit["process"]["resource"], resource.0); + assert_eq!(limit_hit["process"]["process"], process.process.0); + assert_eq!(limit_hit["event"]["event"], "limit_hit"); + assert_eq!(limit_hit["event"]["limit"], "timeout"); + + let exited = receive_event(&mut events).await?; + let exited: Value = serde_json::from_value(exited.body[0].clone())?; + assert_eq!(exited["process"]["resource"], resource.0); + assert_eq!(exited["process"]["process"], process.process.0); + assert_eq!(exited["event"]["event"], "exited"); + assert_eq!(exited["event"]["status"], Value::Null); + proxy.call::<()>("Close", (resource,)).await?; + Ok(()) +} + #[tokio::test] async fn local_grant_discovery_refuses_home_ancestors_of_credentials() -> Result<(), Box> { @@ -1244,6 +1423,229 @@ async fn existing_shell_method_keeps_its_two_argument_bus_codec() Ok(()) } +fn run_native_shell_request( + command: &str, + state_dir: &std::path::Path, + yield_after_ms: Option, +) -> tinybox_bus::RunNativeShellRequest { + tinybox_bus::RunNativeShellRequest { + create: tinybox_bus::CreateRequest { + resource: tinybox_bus::ResourceId(String::new()), + backend: "passthrough".into(), + host: tinybox_bus::HostConfig::Local, + workspace: tinybox_bus::Workspace::Directory(".".into()), + ..Default::default() + }, + shell: tinybox_bus::ShellExecRequest { + process: tinybox_bus::ResourceId(String::new()), + resource: tinybox_bus::ResourceId(String::new()), + shell: tinybox_bus::SelectShellRequest { + command: command.into(), + preserve_pipeline_status: false, + runtime_path: None, + python_utf8: false, + }, + cwd: None, + extra_env: std::collections::BTreeMap::default(), + state_dir: state_dir.to_string_lossy().into_owned(), + timeout_ms: 5_000, + stdin: None, + jail: None, + hide_window: false, + }, + yield_after_ms, + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn run_native_shell_returns_finished_output_after_module_cleanup() +-> Result<(), Box> { + let state = tempfile::tempdir()?; + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let _storage_host = serve_test_storage(&bus).await?; + let service = Connection::connect(bus.connect().await?).await?; + setup(service.clone()).await?; + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; + + let result: tinybox_bus::RunNativeShellResult = proxy + .call( + "RunNativeShell", + (run_native_shell_request( + "echo finished", + state.path(), + None, + ),), + ) + .await?; + let tinybox_bus::RunNativeShellResult::Finished { + exit_code, + stdout, + timed_out, + .. + } = result + else { + return Err(format!("short shell should finish before returning: {result:?}").into()); + }; + assert_eq!(exit_code, 0, "timed_out={timed_out}, stdout={stdout:?}"); + // cmd ends `echo` lines with CRLF. + assert_eq!( + String::from_utf8_lossy(&stdout).replace("\r\n", "\n"), + "finished\n" + ); + assert!(!timed_out); + assert_eq!( + std::fs::read_dir(state.path().join("artifacts/sandbox-scratch"))?.count(), + 0 + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn run_native_shell_cancels_a_shell_that_outlives_its_timeout() +-> Result<(), Box> { + let state = tempfile::tempdir()?; + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let _storage_host = serve_test_storage(&bus).await?; + let service = Connection::connect(bus.connect().await?).await?; + setup(service.clone()).await?; + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; + let mut request = run_native_shell_request("sleep 30", state.path(), None); + request.shell.timeout_ms = 300; + + let result: tinybox_bus::RunNativeShellResult = tokio::time::timeout( + std::time::Duration::from_secs(20), + proxy.call("RunNativeShell", (request,)), + ) + .await??; + + assert!( + matches!( + result, + tinybox_bus::RunNativeShellResult::Finished { + timed_out: true, + .. + } + ), + "{result:?}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn run_native_shell_reports_a_failed_start_as_a_backend_error() +-> Result<(), Box> { + let state = tempfile::tempdir()?; + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let _storage_host = serve_test_storage(&bus).await?; + let service = Connection::connect(bus.connect().await?).await?; + setup(service.clone()).await?; + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; + let mut request = run_native_shell_request("echo never", state.path(), None); + request.create.backend = "no-such-backend".into(); + + let failed = proxy + .call::("RunNativeShell", (request,)) + .await; + + assert!( + matches!( + &failed, + Err(tinybus::Error::MethodFailed { name, .. }) if name == tinybox_bus::BACKEND_ERROR + ), + "{failed:?}" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn run_native_shell_yields_an_owned_process_for_existing_output_operations() +-> Result<(), Box> { + let state = tempfile::tempdir()?; + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let _storage_host = serve_test_storage(&bus).await?; + let service = Connection::connect(bus.connect().await?).await?; + setup(service.clone()).await?; + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; + + let result: tinybox_bus::RunNativeShellResult = proxy + .call( + "RunNativeShell", + (run_native_shell_request( + // The shell is cmd on Windows, where `;` does not separate commands. + if cfg!(windows) { + "ping -n 2 127.0.0.1 >nul & echo yielded" + } else { + "sleep 0.2; echo yielded" + }, + state.path(), + Some(10), + ),), + ) + .await?; + let tinybox_bus::RunNativeShellResult::Running { + resource, + process, + mut cursor, + stdout, + stderr, + } = result + else { + return Err(format!("long shell should yield while running: {result:?}").into()); + }; + let mut output = stdout; + output.extend(stderr); + let batch = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let batch: tinybox_bus::OutputBatch = + proxy.call("ReadOutput", (process.clone(), cursor)).await?; + cursor = batch.next_sequence; + output.extend(batch.chunks.iter().flat_map(|chunk| chunk.bytes.clone())); + if batch.state != tinybox_bus::ExecutionState::Running { + break Ok::<_, tinybus::Error>(batch); + } + } + }) + .await??; + assert_eq!( + batch.state, + tinybox_bus::ExecutionState::Exited { exit_code: 0 } + ); + assert_eq!( + String::from_utf8_lossy(&output).replace("\r\n", "\n"), + "yielded\n" + ); + proxy.call::<()>("ReleaseOutput", (process,)).await?; + proxy.call::<()>("Close", (resource,)).await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn dropping_run_native_shell_invocation_cancels_and_joins_cleanup() +-> Result<(), Box> { + let state = tempfile::tempdir()?; + let service = std::sync::Arc::new(BoxService::default()); + let task_service = service.clone(); + let request = run_native_shell_request("sleep 30", state.path(), None); + let invocation = tokio::spawn(async move { task_service.run_native_shell(request).await }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + invocation.abort(); + + tokio::time::timeout( + std::time::Duration::from_secs(5), + service.resources.shutdown(), + ) + .await??; + Ok(()) +} + #[tokio::test] async fn shell_discovery_preserves_the_requested_pipeline_status() -> tinybus::Result<()> { let service = BoxService::default(); diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index d4a41083..2d7c0799 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -56,18 +56,7 @@ pub(super) fn prepare( jail.read_only = grants(&config.read_only)?; jail.read_write = grants(&config.read_write)?; jail.deny_read = denials(&config.deny_read)?; - jail.resources = config.resources.map(|limits| tinybox_core::Resources { - cpu_millis: limits.cpu_millis, - memory_bytes: limits.memory_bytes, - pids_max: limits.pids_max, - disk_bytes: limits.disk_bytes, - }); - if jail - .resources - .is_some_and(|limits| limits.validate().is_err()) - { - return Err(invalid()); - } + configure_resources(&mut jail, config)?; // A denial over the root or a writable grant would leave the command no // workspace; a denial nested inside a writable grant is safe only for a // backend that applies denials after its write allows. @@ -124,6 +113,43 @@ pub(super) fn prepare( } Ok(jail) } + +fn configure_resources(jail: &mut Jail, config: &NativeJailConfig) -> Result<()> { + jail.resources = config.resources.map(|limits| tinybox_core::Resources { + cpu_millis: limits.cpu_millis, + memory_bytes: limits.memory_bytes, + pids_max: limits.pids_max, + disk_bytes: limits.disk_bytes, + }); + jail.hard_requirements + .extend(required_resource_constraints(config.resources)); + if jail + .resources + .is_some_and(|limits| limits.validate().is_err()) + { + return Err(invalid()); + } + Ok(()) +} + +fn required_resource_constraints( + resources: Option, +) -> Vec { + if resources.is_some() { + // An explicitly supplied native jail budget is a hard request. The + // default values in a BoxSpec remain soft unless its own + // `hard_requirements` lists a constraint. + vec![ + tinybox_core::Constraint::Cpu, + tinybox_core::Constraint::Memory, + tinybox_core::Constraint::Pids, + tinybox_core::Constraint::Disk, + ] + } else { + Vec::new() + } +} + /// Validate and canonicalize each existing absolute grant path. fn grants(paths: &[String]) -> Result> { paths diff --git a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs index 999cb121..ebbab801 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -914,6 +914,12 @@ async fn native_cancellation_kills_detached_descendants_and_preserves_stream_eve exec.cwd = Some(root.path().into()); let backend = resource_backend(); let jail = prepare_with_resources(root.path(), &request, &mut exec, backend.as_ref())?; + for key in ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"] { + if let Ok(value) = std::env::var(key) { + exec.env.insert(key.to_owned(), value); + } + } + let approved_env = exec.env.clone(); let host = Arc::new(tinybox_host::JailLocalHost::with_backend( jail, backend, @@ -923,12 +929,15 @@ async fn native_cancellation_kills_detached_descendants_and_preserves_stream_eve command .args(exec.argv.iter().skip(1)) .env_clear() - .envs(exec.env); + .envs(&approved_env); let journal = Arc::new(super::super::output::OutputJournal::default()); let runner = host.clone(); let observer = journal.clone(); - let task = - tokio::spawn(async move { runner.run_command_observed(command, None, observer).await }); + let task = tokio::spawn(async move { + runner + .run_command_observed_with_env(command, None, observer, approved_env) + .await + }); let deadline = tokio::time::Instant::now() + Duration::from_secs(10); let mut ready = false; while tokio::time::Instant::now() < deadline { diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs index 6943cd56..f18cbb1f 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -6,6 +6,7 @@ use std::{ use tinybox_bus::ShellExecRequest; use tinybus::Result; +pub(super) mod run; mod types; pub(super) use types::ShellContext; use types::ShellHost; @@ -157,8 +158,13 @@ impl ShellContext { .await } ShellHost::Jailed(host) => { - host.run_command_observed(command, self.request.stdin.clone(), observer) - .await + host.run_command_observed_with_env( + command, + self.request.stdin.clone(), + observer, + self.environment.clone(), + ) + .await } } } diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs index 89ea694d..485dbb02 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs @@ -125,6 +125,11 @@ async fn native_shell_jail_adds_owned_scratch_and_refuses_unsupported_constraint let mut resolved = tinybox_core::ExecRequest::new(["unused"]); resolved.cwd = Some(state.path().to_path_buf()); let mut request = request(state.path()); + // The approved environment reaches the jailed shell. + request.shell.command = r#"test "$TINYBOX_APPROVED" = visible"#.into(); + request + .extra_env + .insert("TINYBOX_APPROVED".into(), "visible".into()); request.jail = Some(tinybox_bus::NativeJailConfig { read_only: vec![], read_write: vec![], diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/run.rs b/crates/tinybox-module/src/tinybus_module/native_shell/run.rs new file mode 100644 index 00000000..22c8db57 --- /dev/null +++ b/crates/tinybox-module/src/tinybus_module/native_shell/run.rs @@ -0,0 +1,126 @@ +use std::{future::Future, time::Duration}; + +use tinybox_bus::{ + CreateRequest, ProcessRef, ReserveRequest, ResourceId, RunNativeShellRequest, + RunNativeShellResult, ShellExecRequest, +}; +use tinybox_host::{NativeShellClient, NativeShellRunOutcome}; +use tinybus::Result; + +use super::super::resources::Resources; + +struct Client(std::sync::Arc); + +impl NativeShellClient for Client { + type Error = String; + + async fn reserve( + &self, + request: ReserveRequest, + ) -> std::result::Result { + self.0 + .reserve(request) + .await + .map_err(|error| error.to_string()) + } + + async fn create(&self, request: CreateRequest) -> std::result::Result<(), Self::Error> { + self.0 + .create(request) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } + + async fn start_native_shell_exec( + &self, + request: ShellExecRequest, + ) -> std::result::Result { + self.0 + .start_shell_exec(request) + .await + .map_err(|error| error.to_string()) + } + + async fn read_output( + &self, + process: ProcessRef, + cursor: u64, + ) -> std::result::Result { + // The host helper expects transport polling to yield between empty + // reads. This in-process adapter otherwise returns a ready future and + // can starve the shell's output collector on a single-worker module. + tokio::task::yield_now().await; + self.0 + .read_output(&process, cursor) + .await + .map_err(|error| error.to_string()) + } + + async fn cancel(&self, process: ProcessRef) -> std::result::Result<(), Self::Error> { + self.0 + .cancel(&process) + .await + .map_err(|error| error.to_string()) + } + + async fn release_output(&self, process: ProcessRef) -> std::result::Result<(), Self::Error> { + self.0 + .release_output(&process) + .await + .map_err(|error| error.to_string()) + } + + async fn close(&self, resource: ResourceId) -> std::result::Result<(), Self::Error> { + self.0 + .close(&resource) + .await + .map_err(|error| error.to_string()) + } +} + +/// Supervises a native shell until it finishes, times out, is cancelled, or +/// reaches the requested yield point. The dropped request receiver is used as +/// the cancellation signal so this future always awaits native cleanup. +pub(in crate::tinybus_module) async fn run( + resources: std::sync::Arc, + request: RunNativeShellRequest, + abandoned: impl Future + Send, +) -> Result { + let outcome = tinybox_host::run_native_shell( + &Client(resources), + tinybox_host::NativeShellRunRequest { + create: request.create, + shell: request.shell, + yield_after: request.yield_after_ms.map(Duration::from_millis), + }, + abandoned, + |_| {}, + ) + .await + .map_err(|error| { + let name = match error { + tinybox_host::NativeShellError::Execution( + tinybox_bus::ExecutionFailure::OutputLimit, + ) => tinybox_bus::OUTPUT_LIMIT, + _ => tinybox_bus::BACKEND_ERROR, + }; + super::super::resources::failure(name, &format!("native shell operation failed: {error:?}")) + })?; + + Ok(match outcome { + NativeShellRunOutcome::Finished(output) => RunNativeShellResult::Finished { + exit_code: output.exit_code, + stdout: output.stdout, + stderr: output.stderr, + timed_out: output.timed_out, + }, + NativeShellRunOutcome::Running(running) => RunNativeShellResult::Running { + resource: running.resource, + process: running.process, + cursor: running.cursor, + stdout: running.stdout, + stderr: running.stderr, + }, + }) +} diff --git a/crates/tinybox-module/src/tinybus_module/output.rs b/crates/tinybox-module/src/tinybus_module/output.rs index ee7fcfb9..4ab59b5f 100644 --- a/crates/tinybox-module/src/tinybus_module/output.rs +++ b/crates/tinybox-module/src/tinybus_module/output.rs @@ -1,9 +1,12 @@ //! Module-owned bounded output journal and cooperative execution cancellation. -use std::sync::Mutex; +use std::sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, +}; use tinybox_bus::{ ExecutionFailure, ExecutionState, MAX_OUTPUT_BYTES, OutputBatch, OutputChunk, OutputStream, }; -use tinybox_core::{Error, ExecutionObserver, Result}; +use tinybox_core::{Error, ExecutionObserver, Result, TerminationCause}; const FRAME_BYTES: usize = 8192; const BATCH_BYTES: usize = 64 * 1024; @@ -30,8 +33,12 @@ impl Default for JournalState { pub(super) struct OutputJournal { state: Mutex, cancelled: tokio::sync::Notify, + cpu_quota_hit: AtomicBool, } impl OutputJournal { + pub(super) fn cpu_quota_hit(&self) -> bool { + self.cpu_quota_hit.load(Ordering::SeqCst) + } pub(super) fn read(&self, cursor: u64) -> Result { let state = self .state @@ -97,7 +104,11 @@ impl OutputJournal { } ) { - state.state = ExecutionState::Failed { code }; + state.state = if state.cancelled && code == ExecutionFailure::BackendFailed { + ExecutionState::Cancelled + } else { + ExecutionState::Failed { code } + }; } } pub(super) fn cleanup_acknowledged(&self) -> bool { @@ -145,6 +156,11 @@ impl ExecutionObserver for OutputJournal { state.bytes = total; Ok(()) } + fn termination_cause(&self, cause: TerminationCause) { + if cause == TerminationCause::CpuQuota { + self.cpu_quota_hit.store(true, Ordering::SeqCst); + } + } async fn cancelled(&self) { let cancelled = self.cancelled.notified(); tokio::pin!(cancelled); diff --git a/crates/tinybox-module/src/tinybus_module/output_tests.rs b/crates/tinybox-module/src/tinybus_module/output_tests.rs index 5d8d8f41..4869fb7c 100644 --- a/crates/tinybox-module/src/tinybus_module/output_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/output_tests.rs @@ -129,6 +129,18 @@ fn cleanup_failure_is_observable_and_kept_distinct_from_acknowledged_cancel() Ok(()) } +#[test] +fn cancellation_wins_when_command_failure_is_recorded_after_the_cancel_signal() +-> std::result::Result<(), Box> { + let journal = OutputJournal::default(); + // This is the interleaving where the collector finished with an error + // just after the stream task checked cancel_requested(). + journal.cancel(); + journal.finish_failed(ExecutionFailure::BackendFailed); + assert_eq!(journal.read(0)?.state, ExecutionState::Cancelled); + Ok(()) +} + #[test] fn empty_output_does_not_consume_sequence_or_memory() -> std::result::Result<(), Box> { diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index b6282b95..3a2b5add 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -14,7 +14,7 @@ enum NativeJailRequest { Resources(tinybox_bus::NativeJailRequest), } -pub(super) struct StreamExecution { +pub(in crate::tinybus_module) struct StreamExecution { request: SpawnRequest, shell: Option, platform_shell: Option, @@ -23,6 +23,7 @@ pub(super) struct StreamExecution { task: Mutex>>, collector: Collector, timed_out: Arc, + event_publisher_claimed: std::sync::atomic::AtomicBool, } #[derive(Clone)] enum Collector { @@ -348,8 +349,62 @@ impl StreamExecution { task: Mutex::new(Some(task)), collector, timed_out, + event_publisher_claimed: std::sync::atomic::AtomicBool::new(false), } } + pub(in crate::tinybus_module) fn claim_event_publisher(&self) -> bool { + !self + .event_publisher_claimed + .swap(true, std::sync::atomic::Ordering::AcqRel) + } + pub(in crate::tinybus_module) async fn wait_for_terminal_exit(&self) -> Option { + let mut task = self.task.lock().await; + if let Some(join) = task.as_mut() + && join.await.is_err() + { + task.take(); + return None; + } + task.take(); + match self.journal.read(0).ok()?.state { + tinybox_bus::ExecutionState::Exited { exit_code } => Some(exit_code), + tinybox_bus::ExecutionState::Running + | tinybox_bus::ExecutionState::Cancelled + | tinybox_bus::ExecutionState::Failed { .. } => None, + } + } + pub(in crate::tinybus_module) fn timed_out(&self) -> bool { + self.timed_out.load(std::sync::atomic::Ordering::SeqCst) + } + pub(in crate::tinybus_module) fn terminal_events( + &self, + status: Option, + ) -> Vec { + use tinybox_bus::{LimitKind, SandboxEvent}; + + let mut events = Vec::new(); + if self.journal.cpu_quota_hit() { + events.push(SandboxEvent::LimitHit { + limit: LimitKind::Cpu, + }); + } + if self.timed_out() { + events.push(SandboxEvent::LimitHit { + limit: LimitKind::Timeout, + }); + // A timed-out process has ended only once its cleanup is acknowledged. + if self.journal.cleanup_acknowledged() { + events.push(SandboxEvent::Exited { status: None }); + } + return events; + } + if let Some(status) = status { + events.push(SandboxEvent::Exited { + status: Some(status), + }); + } + events + } pub(super) fn running(&self) -> bool { !self.journal.cleanup_acknowledged() } @@ -682,7 +737,10 @@ impl Resources { error.map_or(Ok(()), Err) } - pub(super) fn stream(&self, process: &ProcessRef) -> Result>> { + pub(in crate::tinybus_module) fn stream( + &self, + process: &ProcessRef, + ) -> Result>> { validate_id(&process.resource)?; validate_id(&process.process)?; let streams = self diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index a11fe017..30863208 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -74,6 +74,78 @@ async fn live_admission_is_bounded_until_outputs_are_explicitly_released() -> Re Ok(()) } +#[test] +fn cpu_quota_observer_cause_is_forwarded_before_process_exit() { + use tinybox_core::ExecutionObserver; + + let journal = Arc::new(OutputJournal::default()); + journal.termination_cause(tinybox_core::TerminationCause::CpuQuota); + let stream = stream_with(journal, false); + + assert_eq!( + stream.terminal_events(Some(0)), + vec![ + tinybox_bus::SandboxEvent::LimitHit { + limit: tinybox_bus::LimitKind::Cpu, + }, + tinybox_bus::SandboxEvent::Exited { status: Some(0) }, + ] + ); +} + +#[test] +fn a_timed_out_stream_keeps_its_cpu_event_and_exits_only_once_cleaned_up() { + use tinybox_bus::{LimitKind, SandboxEvent}; + use tinybox_core::ExecutionObserver; + + let cpu = SandboxEvent::LimitHit { + limit: LimitKind::Cpu, + }; + let timeout = SandboxEvent::LimitHit { + limit: LimitKind::Timeout, + }; + let journal = Arc::new(OutputJournal::default()); + journal.termination_cause(tinybox_core::TerminationCause::CpuQuota); + journal.finish_failed(ExecutionFailure::CleanupFailed); + let stream = stream_with(journal.clone(), true); + assert_eq!( + stream.terminal_events(None), + vec![cpu.clone(), timeout.clone()] + ); + journal.finish_failed(ExecutionFailure::TimedOut); + assert_eq!( + stream.terminal_events(None), + vec![cpu, timeout, SandboxEvent::Exited { status: None }] + ); +} + +fn stream_with(journal: Arc, timed_out: bool) -> StreamExecution { + let process = ProcessRef { + resource: ResourceId("resource".into()), + process: ResourceId("process".into()), + }; + StreamExecution { + request: SpawnRequest { + process: process.process.clone(), + command: ExecRequest { + resource: process.resource.clone(), + argv: true_command(), + cwd: None, + env: std::collections::BTreeMap::new(), + stdin: None, + }, + }, + shell: None, + platform_shell: None, + jail: None, + journal, + task: Mutex::new(None), + collector: Collector::Host(Arc::new(tinybox_host::LimitedLocalHost::new(64)), None), + timed_out: Arc::new(std::sync::atomic::AtomicBool::new(timed_out)), + event_publisher_claimed: std::sync::atomic::AtomicBool::new(false), + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn stream_replay_keeps_request_identity_and_rejects_cross_resource_handles() -> Result<()> { let resources = Resources::test_resources(); @@ -209,6 +281,7 @@ async fn a_panicked_job_is_joined_and_cleanup_failure_stays_owned_until_retry() shell: None, platform_shell: None, timed_out: Arc::new(std::sync::atomic::AtomicBool::new(false)), + event_publisher_claimed: std::sync::atomic::AtomicBool::new(false), journal, task: Mutex::new(Some(task)), collector: Collector::Host(Arc::new(tinybox_host::LimitedLocalHost::new(64)), None), @@ -889,9 +962,11 @@ async fn cancelled_native_shell_retains_scratch_until_output_release() }) .await??; resources.cancel(&process).await?; + let output = resources.read_output(&process, 0).await?; assert_eq!( - resources.read_output(&process, 0).await?.state, - ExecutionState::Cancelled + output.state, + ExecutionState::Cancelled, + "cancellation failed after captured output: {output:?}" ); let scratch = state.path().join("artifacts/sandbox-scratch"); assert_eq!(std::fs::read_dir(&scratch)?.count(), 1); @@ -1414,6 +1489,18 @@ fn native_resource_bus_available() -> bool { available } +#[cfg(target_os = "linux")] +fn native_resource_test_environment() -> std::collections::BTreeMap { + ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"] + .into_iter() + .filter_map(|name| { + std::env::var(name) + .ok() + .map(|value| (name.to_owned(), value)) + }) + .collect() +} + #[cfg(target_os = "linux")] fn detached_descendant_script() -> String { "import os,time,sys\nfirst=os.fork()\nif first == 0:\n os.setsid()\n second=os.fork()\n if second == 0:\n with open(sys.argv[1],'w') as f: f.write(f'{os.getpid()} {os.stat(\"/proc/self/ns/pid\").st_ino}')\n while True: time.sleep(1)\n os._exit(0)\nos.waitpid(first,0)\nwhile not os.path.exists(sys.argv[1]) or os.path.getsize(sys.argv[1]) == 0: time.sleep(0.01)\nprint('stdout-ready',flush=True)\nprint('stderr-ready',file=sys.stderr,flush=True)\ntime.sleep(600)".into() @@ -1447,7 +1534,7 @@ async fn start_resource_descendant( pid_file.display().to_string(), ], cwd: Some(root.display().to_string()), - env: std::collections::BTreeMap::default(), + env: native_resource_test_environment(), stdin: None, }, }, @@ -1496,7 +1583,7 @@ async fn wait_for_resource_descendant( let streams_ready = !check_streams || (stream_has(tinybox_bus::OutputStream::Stdout, b"stdout-ready") && stream_has(tinybox_bus::OutputStream::Stderr, b"stderr-ready")); - if streams_ready && std::fs::metadata(pid_file).is_ok() { + if streams_ready && std::fs::metadata(pid_file).is_ok_and(|metadata| metadata.len() > 0) { let identity = std::fs::read_to_string(pid_file)?; let mut parts = identity.split_whitespace(); return Ok(( diff --git a/crates/tinybox-ssh/src/host/observed.rs b/crates/tinybox-ssh/src/host/observed.rs index a1debd80..3e8bf1a7 100644 --- a/crates/tinybox-ssh/src/host/observed.rs +++ b/crates/tinybox-ssh/src/host/observed.rs @@ -411,6 +411,10 @@ impl ExecutionObserver for Bridge { Ok(()) } + fn termination_cause(&self, cause: tinybox_core::TerminationCause) { + self.observer.termination_cause(cause); + } + async fn cancelled(&self) { let stopped = self.stop.notified(); tokio::pin!(stopped); diff --git a/docs/adr/0003-workspace-split-to-contain-unsafe.md b/docs/adr/0003-workspace-split-to-contain-unsafe.md index eded86ad..c7648a49 100644 --- a/docs/adr/0003-workspace-split-to-contain-unsafe.md +++ b/docs/adr/0003-workspace-split-to-contain-unsafe.md @@ -3,16 +3,20 @@ - **Status:** Accepted, amended by [ADR 0005](0005-namespaces-through-bubblewrap.md) - **Date:** 2026-08-14 -> **Amendment.** The `unsafe` reservation described below was never needed. -> Modern Ubuntu blocks unprivileged user namespaces for unconfined binaries, so -> the namespace backend drives `bwrap` instead of calling `clone` directly and -> `unsafe_code = "forbid"` holds across the whole workspace with no exception. -> The rest of this decision — the split itself, and why — stands. +> **Amendment.** Modern Ubuntu blocks unprivileged user namespaces for +> unconfined binaries, so the namespace backend drives `bwrap` instead of +> calling `clone` directly. The Windows AppContainer backend now has one +> narrowly scoped FFI exception at +> `crates/tinybox-jail/src/windows.rs`; the workspace default remains +> `unsafe_code = "deny"`, and CI rejects allowances in every other Rust source +> file. The rest of this decision — the split itself, and why — stands. ## Context -`Cargo.toml` sets `unsafe_code = "forbid"`. Unlike `deny`, `forbid` cannot be -cancelled by a local `#[allow]` — that is the point of choosing it. +`Cargo.toml` sets the workspace default to `unsafe_code = "deny"`. The Windows +AppContainer APIs require a small Win32 FFI boundary; keeping the exception in +one file makes that boundary reviewable without relaxing the lint for the rest +of the workspace. A rootless namespaces backend cannot honor that. It needs `clone` with namespace flags, `unshare`, `pivot_root`, `mount`, and a seccomp filter loaded @@ -22,12 +26,14 @@ async-signal-safe and no safe abstraction exists. Three options were available: -1. Relax `unsafe_code` to `deny` crate-wide and use targeted `#[allow]`s. +1. Relax `unsafe_code` to `deny` crate-wide and allow unsafe throughout the + platform backend. 2. Stay on `forbid` and shell out to a helper binary for the unsafe parts. -3. Split into a workspace and allow `unsafe` in exactly one member. +3. Keep the workspace default at `deny` and permit unsafe in one audited + Windows FFI file, checked by CI. -Option 1 removes the guarantee everywhere to serve one module: the model types, -the bus adapter, and the CLI would all lose a lint they have no need to lose. +Option 1 spreads the exception across a platform backend and makes the boundary +harder to audit. Option 2 keeps the guarantee but pays a process spawn on every box creation and moves the hard part into an unversioned binary that is harder to test than a library. @@ -41,16 +47,18 @@ deployment uses Docker or SSH. ## Decision Convert the repository to a Cargo workspace. Hoist the lint configuration into -`[workspace.lints]` so every member inherits identical settings, and let -`tinybox-linux` — and only `tinybox-linux` — override `unsafe_code` to `deny`, -with every invariant documented in a `// SAFETY:` comment. +`[workspace.lints]` so every member inherits identical settings. Keep +`unsafe_code = "deny"` as the default and allow it only in +`crates/tinybox-jail/src/windows.rs`; the CI boundary check rejects allowances +in other production Rust files. The Windows FFI stays in that module, with each +invariant documented at the relevant call. ```text crates/ ├── tinybox-core/ # model and traits; unsafe FORBIDDEN ├── tinybox-host/ # LocalHost, SshHost ├── tinybox-docker/ # DockerSandbox -├── tinybox-linux/ # NamespaceSandbox; unsafe DENIED, documented +├── tinybox-linux/ # NamespaceSandbox; safe bubblewrap launcher ├── tinybox-cli/ # bin `tinybox` └── tinybox-module/ # cdylib, TinyBus ABI v1 ``` diff --git a/docs/adr/0005-namespaces-through-bubblewrap.md b/docs/adr/0005-namespaces-through-bubblewrap.md index ff4b64c2..775e0806 100644 --- a/docs/adr/0005-namespaces-through-bubblewrap.md +++ b/docs/adr/0005-namespaces-through-bubblewrap.md @@ -56,9 +56,12 @@ applications with. `tinybox-linux` drives `bwrap` through its [`Host`](0004-drive-backends-through-the-host-trait.md), exactly as the Docker backend drives `docker`. -`unsafe` is **not** enabled anywhere. `unsafe_code = "forbid"` stays in -`[workspace.lints]` with no exception, and the reservation ADR 0003 made for -this crate is withdrawn. +The Linux namespace backend uses bubblewrap and contains no unsafe code. The +workspace default remains `unsafe_code = "deny"`; the only local allowance is +the Win32 FFI boundary in `crates/tinybox-jail/src/windows.rs`, documented in +ADR 0003. CI checks that no other Rust source file adds an unsafe-code +allowance. The reservation ADR 0003 made for the Linux backend remains +withdrawn. Rootless cgroup v2 limits go through `systemd-run --user --scope`, which is the only route an unprivileged process has to a delegated cgroup. They are opt-in on diff --git a/docs/specs/contract-2-2.md b/docs/specs/contract-2-2.md new file mode 100644 index 00000000..b9d182be --- /dev/null +++ b/docs/specs/contract-2-2.md @@ -0,0 +1,24 @@ +# TinyBox contract 2.2 + +Contract 2.2 adds one broadcast signal to `ai.tinyhumans.tinybox.Box`: + +| Signal | Body | +| --- | --- | +| `ProcessEvent` | `ProcessEvent { process: ProcessRef, event: SandboxEvent }` | + +The module emits `Started` after the first successful `StartExec`, +`StartJailedExec`, `StartJailedExecWithResources`, `StartShellExec`, or +`StartNativeShellExec` for a process reservation, then emits +`Exited { status: Some(code) }` after its supervisor observes the terminal +exit status. A replayed start does not duplicate lifecycle events. For +executions terminated by their wall-clock timeout, the module emits +`LimitHit { limit: Timeout }` before `Exited { status: None }`. +For Linux native executions in a scoped cgroup-v2 CPU quota, a positive +`nr_throttled` counter emits `LimitHit { limit: Cpu }` before the exit event. +This indicates that the quota throttled execution; it does not indicate CPU +exhaustion or a process failure. + +Signals are observational and best-effort. Their bodies include the caller's +resource and process reservations for correlation. This contract does not +report stdio process events, access denials, or other resource-limit +observations. diff --git a/docs/specs/contract-2-3.md b/docs/specs/contract-2-3.md new file mode 100644 index 00000000..cb1fe05d --- /dev/null +++ b/docs/specs/contract-2-3.md @@ -0,0 +1,12 @@ +# TinyBox contract 2.3 + +Contract 2.3 adds two things to `ai.tinyhumans.tinybox.Box`, without changing +any 2.2 request or reply shape: + +| Addition | What a host may rely on | +| --- | --- | +| `RunNativeShell` | The module runs a native shell through completion, or until the requested yield point, and cleans up when the caller abandons it. | +| `openshell` backend | `Create { backend: "openshell", .. }` places the box in an NVIDIA OpenShell sandbox behind the configured gateway. `Capabilities` lists `openshell` in the create and exec backends. | + +A host that needs either one requires `contract_version >= (2, 3)`. Modules +that report 2.1 or 2.2 answer an `openshell` create with `UnsupportedBackend`. diff --git a/scripts/ci/check-windows-unsafe-boundary.sh b/scripts/ci/check-windows-unsafe-boundary.sh new file mode 100755 index 00000000..f8837069 --- /dev/null +++ b/scripts/ci/check-windows-unsafe-boundary.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# The workspace denies `unsafe_code`; only the Windows AppContainer FFI file may +# allow it, file-scoped. Any other code mention of the lint is a relaxation, +# whatever its spelling: `allow`, `expect`, `warn`, `cfg_attr(.., allow(..))`, +# a multi-line attribute, or a crate manifest override. Comment lines are prose. +# Every crate must inherit the workspace lints, or it has no unsafe_code lint. +set -euo pipefail + +root="${1:-.}" +cd "$root" + +allowed_file="crates/tinybox-jail/src/windows.rs" +allowed_line='#![allow(unsafe_code)]' + +found="$( + grep -RIn --include='*.rs' --include='Cargo.toml' -e 'unsafe_code' . \ + --exclude-dir=vendor --exclude-dir=target --exclude-dir=.git | + grep -Ev '^[^:]+:[0-9]+:[[:space:]]*//' || true +)" +expected="./$allowed_file:" + +violations="$( + printf '%s\n' "$found" | grep -v '^$' | while IFS= read -r hit; do + file="${hit%%:*}" + text="${hit#*:}" + text="${text#*:}" + if [[ "$file" == ./Cargo.toml && "$text" =~ ^unsafe_code\ =\ \"deny\"$ ]]; then + continue + fi + if [[ "$hit" == "$expected"* && "$text" == "$allowed_line" ]]; then + continue + fi + printf '%s\n' "$hit" + done +)" + +if [[ -n "$violations" ]]; then + printf 'unsafe_code may be relaxed only by `%s` in %s\nfound:\n%s\n' \ + "$allowed_line" "$allowed_file" "$violations" >&2 + exit 1 +fi + +if ! grep -Eq '^unsafe_code = "deny"$' Cargo.toml; then + echo 'workspace unsafe_code lint must remain deny by default' >&2 + exit 1 +fi + +for manifest in crates/*/Cargo.toml; do + if ! awk '/^\[lints\]$/ { getline; found = $0 == "workspace = true" } END { exit !found }' "$manifest"; then + printf '%s must inherit the workspace lints: [lints] workspace = true\n' "$manifest" >&2 + exit 1 + fi +done + +if [[ "$(grep -cxF "$allowed_line" "$allowed_file")" != 1 ]]; then + echo 'Windows FFI exception must remain one file-scoped allowance' >&2 + exit 1 +fi diff --git a/scripts/ci/check-windows-unsafe-boundary_tests.sh b/scripts/ci/check-windows-unsafe-boundary_tests.sh new file mode 100755 index 00000000..fe1fc7fe --- /dev/null +++ b/scripts/ci/check-windows-unsafe-boundary_tests.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Behavioral checks for check-windows-unsafe-boundary.sh: every spelling of a +# relaxation fails, prose and the one allowance pass. +set -euo pipefail + +guard="$(cd "$(dirname "$0")" && pwd)/check-windows-unsafe-boundary.sh" +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT +failures=0 + +tree() { + local dir="$scratch/$1" + mkdir -p "$dir/crates/tinybox-jail/src" "$dir/crates/other/src" + printf '[workspace.lints.rust]\nunsafe_code = "deny"\n' > "$dir/Cargo.toml" + printf '//! Windows.\n#![allow(unsafe_code)]\n' > "$dir/crates/tinybox-jail/src/windows.rs" + printf '//! Mentions `unsafe_code` in prose only.\n' > "$dir/crates/other/src/lib.rs" + printf '[package]\nname = "other"\n\n[lints]\nworkspace = true\n' > "$dir/crates/other/Cargo.toml" + printf '%s' "$dir" +} + +expect() { + local want="$1" name="$2" dir="$3" + if bash "$guard" "$dir" >/dev/null 2>&1; then got=pass; else got=fail; fi + if [[ "$got" != "$want" ]]; then + printf 'FAIL %s: expected %s, got %s\n' "$name" "$want" "$got" >&2 + failures=$((failures + 1)) + fi +} + +expect pass baseline "$(tree baseline)" + +for spelling in \ + '#![allow(unsafe_code)]' \ + '#[expect(unsafe_code)]' \ + '#![warn(unsafe_code)]' \ + '#[cfg_attr(windows, allow(unsafe_code))]' \ + '#[allow(clippy::all, unsafe_code)]'; do + dir="$(tree "case$RANDOM$RANDOM")" + printf '%s\nfn f() {}\n' "$spelling" >> "$dir/crates/other/src/lib.rs" + expect fail "$spelling" "$dir" +done + +dir="$(tree multiline)" +printf '#[cfg_attr(\n windows,\n allow(unsafe_code)\n)]\nfn f() {}\n' >> "$dir/crates/other/src/lib.rs" +expect fail multi-line-attribute "$dir" + +dir="$(tree manifest)" +printf '[lints.rust]\nunsafe_code = "allow"\n' >> "$dir/crates/other/Cargo.toml" +expect fail crate-manifest-override "$dir" + +dir="$(tree no-inheritance)" +printf '[package]\nname = "other"\n' > "$dir/crates/other/Cargo.toml" +expect fail crate-without-workspace-lints "$dir" + +dir="$(tree workspace-allow)" +printf '[workspace.lints.rust]\nunsafe_code = "allow"\n' > "$dir/Cargo.toml" +expect fail workspace-relaxed "$dir" + +dir="$(tree second-allow)" +printf '#[allow(unsafe_code)]\nfn g() {}\n' >> "$dir/crates/tinybox-jail/src/windows.rs" +expect fail item-scoped-allowance-in-windows "$dir" + +dir="$(tree vendored)" +mkdir -p "$dir/vendor/x/src" +printf '#![allow(unsafe_code)]\n' > "$dir/vendor/x/src/lib.rs" +expect pass vendored-code-ignored "$dir" + +if [[ "$failures" -ne 0 ]]; then + exit 1 +fi +echo 'unsafe boundary guard: all cases behave' diff --git a/vendor/tinybus b/vendor/tinybus index 6ce06bc6..06895661 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 6ce06bc674e4f4d718aa51e643ef62c1f372e270 +Subproject commit 0689566109edfc28c416ba927b2af4b88f1fb4cf