feat: implement worker lifecycle and sandbox
This commit is contained in:
parent
170ca32f58
commit
b9b9bfe05a
13 changed files with 2456 additions and 7 deletions
267
Cargo.lock
generated
267
Cargo.lock
generated
|
|
@ -38,7 +38,7 @@ version = "1.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -49,7 +49,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -73,6 +73,21 @@ version = "0.22.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bstr"
|
name = "bstr"
|
||||||
version = "1.13.0"
|
version = "1.13.0"
|
||||||
|
|
@ -84,6 +99,12 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.4"
|
version = "4.6.4"
|
||||||
|
|
@ -130,18 +151,85 @@ version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "difflib"
|
name = "difflib"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
|
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dyn-clone"
|
name = "dyn-clone"
|
||||||
version = "1.0.20"
|
version = "1.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ghidra-cli"
|
name = "ghidra-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -149,9 +237,14 @@ dependencies = [
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
"base64",
|
"base64",
|
||||||
"clap",
|
"clap",
|
||||||
|
"getrandom",
|
||||||
|
"hex",
|
||||||
|
"rustix",
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -161,6 +254,12 @@ version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
|
|
@ -179,12 +278,24 @@ version = "0.2.189"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.3"
|
version = "2.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell_polyfill"
|
name = "once_cell_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
|
|
@ -236,6 +347,12 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ref-cast"
|
name = "ref-cast"
|
||||||
version = "1.0.26"
|
version = "1.0.26"
|
||||||
|
|
@ -262,6 +379,19 @@ version = "0.4.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
|
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.0.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
@ -341,6 +471,17 @@ dependencies = [
|
||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
|
|
@ -369,6 +510,19 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.21.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "termtree"
|
name = "termtree"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|
@ -395,6 +549,12 @@ dependencies = [
|
||||||
"syn 3.0.3",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
|
|
@ -407,6 +567,12 @@ version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wait-timeout"
|
name = "wait-timeout"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|
@ -416,21 +582,116 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.14.3+wasi-0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a51ae83037bdd272a9e28ce236db8c07016dd0d50c27038b3f407533c030c95"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.53.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.1.3",
|
||||||
|
"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]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.53.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.53.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.53.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.53.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.53.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.53.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.53.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.53.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.45.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,25 @@ path = "src/lib.rs"
|
||||||
name = "ghidr"
|
name = "ghidr"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ghidr-fake-child"
|
||||||
|
path = "tests/support/fake_child.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
clap = { version = "4.5.60", features = ["derive"] }
|
clap = { version = "4.5.60", features = ["derive"] }
|
||||||
|
getrandom = "=0.3.3"
|
||||||
|
hex = "0.4.3"
|
||||||
|
rustix = { version = "=1.0.8", features = ["process"] }
|
||||||
schemars = { version = "=1.0.4", features = ["derive"] }
|
schemars = { version = "=1.0.4", features = ["derive"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
|
sha2 = "0.10.9"
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2.1.2"
|
assert_cmd = "2.1.2"
|
||||||
|
tempfile = "=3.21.0"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,13 @@ impl fmt::Display for Digest {
|
||||||
pub struct InvocationId(String);
|
pub struct InvocationId(String);
|
||||||
|
|
||||||
impl InvocationId {
|
impl InvocationId {
|
||||||
|
/// Generates a cryptographically random 128-bit invocation identifier.
|
||||||
|
pub fn generate() -> Result<Self, getrandom::Error> {
|
||||||
|
let mut bytes = [0_u8; 16];
|
||||||
|
getrandom::fill(&mut bytes)?;
|
||||||
|
Ok(Self(hex::encode(bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Parses a canonical invocation identifier.
|
/// Parses a canonical invocation identifier.
|
||||||
pub fn parse(value: &str) -> Result<Self, IdentifierError> {
|
pub fn parse(value: &str) -> Result<Self, IdentifierError> {
|
||||||
if value.len() != 32
|
if value.len() != 32
|
||||||
|
|
@ -51,6 +58,18 @@ impl InvocationId {
|
||||||
}
|
}
|
||||||
Ok(Self(value.to_owned()))
|
Ok(Self(value.to_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the canonical lowercase hexadecimal spelling.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for InvocationId {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(&self.0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identifier validation failure.
|
/// Identifier validation failure.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ pub mod domain;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod operation;
|
pub mod operation;
|
||||||
pub mod output;
|
pub mod output;
|
||||||
|
pub mod process;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
|
pub mod sandbox;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
|
||||||
pub use error::{AppError, ErrorCode, ExitStatus};
|
pub use error::{AppError, ErrorCode, ExitStatus};
|
||||||
|
|
|
||||||
219
src/process/capture.rs
Normal file
219
src/process/capture.rs
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
//! Bounded, byte-exact child stream capture.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
io::{self, Read},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Maximum bytes retained independently for each child stream (8 MiB).
|
||||||
|
pub const STREAM_CAPTURE_BYTES: usize = 8 * 1024 * 1024;
|
||||||
|
/// Bytes retained at each end of a truncated stream (4 MiB).
|
||||||
|
pub const STREAM_SEGMENT_BYTES: usize = STREAM_CAPTURE_BYTES / 2;
|
||||||
|
|
||||||
|
/// A completely drained child stream with fixed-memory capture.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct CapturedStream {
|
||||||
|
total_bytes: u64,
|
||||||
|
content: CapturedContent,
|
||||||
|
utf8: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
enum CapturedContent {
|
||||||
|
Complete(Vec<u8>),
|
||||||
|
Segments { first: Vec<u8>, last: Vec<u8> },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CapturedStream {
|
||||||
|
/// Empty capture used when no child pipe was created.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
total_bytes: 0,
|
||||||
|
content: CapturedContent::Complete(Vec::new()),
|
||||||
|
utf8: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drains a stream to EOF while retaining no more than 8 MiB.
|
||||||
|
pub fn drain(mut reader: impl Read) -> io::Result<Self> {
|
||||||
|
let mut complete = Vec::new();
|
||||||
|
let mut first = Vec::new();
|
||||||
|
let mut last = VecDeque::with_capacity(STREAM_SEGMENT_BYTES);
|
||||||
|
let mut truncated = false;
|
||||||
|
let mut total_bytes = 0_u64;
|
||||||
|
let mut utf8 = Utf8Validator::default();
|
||||||
|
let mut buffer = [0_u8; 64 * 1024];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let read = reader.read(&mut buffer)?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
total_bytes = total_bytes.saturating_add(u64::try_from(read).unwrap_or(u64::MAX));
|
||||||
|
let bytes = &buffer[..read];
|
||||||
|
utf8.push(bytes);
|
||||||
|
if !truncated && complete.len().saturating_add(read) <= STREAM_CAPTURE_BYTES {
|
||||||
|
complete.extend_from_slice(bytes);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !truncated {
|
||||||
|
first.extend_from_slice(&complete[..STREAM_SEGMENT_BYTES]);
|
||||||
|
last.extend(&complete[STREAM_SEGMENT_BYTES..]);
|
||||||
|
complete.clear();
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
push_tail(&mut last, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = if truncated {
|
||||||
|
CapturedContent::Segments {
|
||||||
|
first,
|
||||||
|
last: last.into_iter().collect(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CapturedContent::Complete(complete)
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
total_bytes,
|
||||||
|
content,
|
||||||
|
utf8: utf8.finish(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total bytes observed while draining, including discarded middle bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn total_bytes(&self) -> u64 {
|
||||||
|
self.total_bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact number of retained bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn captured_bytes(&self) -> u64 {
|
||||||
|
match &self.content {
|
||||||
|
CapturedContent::Complete(bytes) => u64::try_from(bytes.len()).unwrap_or(u64::MAX),
|
||||||
|
CapturedContent::Segments { first, last } => {
|
||||||
|
u64::try_from(first.len().saturating_add(last.len())).unwrap_or(u64::MAX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether middle bytes were omitted.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn truncated(&self) -> bool {
|
||||||
|
matches!(self.content, CapturedContent::Segments { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether all retained bytes form valid UTF-8.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_utf8(&self) -> bool {
|
||||||
|
self.utf8
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn segments(&self) -> CapturedSegments<'_> {
|
||||||
|
match &self.content {
|
||||||
|
CapturedContent::Complete(bytes) => CapturedSegments::Complete(bytes),
|
||||||
|
CapturedContent::Segments { first, last } => CapturedSegments::Truncated {
|
||||||
|
first,
|
||||||
|
last,
|
||||||
|
last_offset: self.total_bytes.saturating_sub(last.len() as u64),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Utf8Validator {
|
||||||
|
incomplete: Vec<u8>,
|
||||||
|
invalid: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Utf8Validator {
|
||||||
|
fn push(&mut self, bytes: &[u8]) {
|
||||||
|
if self.invalid {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.incomplete.extend_from_slice(bytes);
|
||||||
|
match std::str::from_utf8(&self.incomplete) {
|
||||||
|
Ok(_) => self.incomplete.clear(),
|
||||||
|
Err(error) if error.error_len().is_none() => {
|
||||||
|
self.incomplete.drain(..error.valid_up_to());
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
self.invalid = true;
|
||||||
|
self.incomplete.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(self) -> bool {
|
||||||
|
!self.invalid && self.incomplete.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum CapturedSegments<'a> {
|
||||||
|
Complete(&'a [u8]),
|
||||||
|
Truncated {
|
||||||
|
first: &'a [u8],
|
||||||
|
last: &'a [u8],
|
||||||
|
last_offset: u64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_tail(tail: &mut VecDeque<u8>, bytes: &[u8]) {
|
||||||
|
if bytes.len() >= STREAM_SEGMENT_BYTES {
|
||||||
|
tail.clear();
|
||||||
|
tail.extend(&bytes[bytes.len() - STREAM_SEGMENT_BYTES..]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let overflow = tail
|
||||||
|
.len()
|
||||||
|
.saturating_add(bytes.len())
|
||||||
|
.saturating_sub(STREAM_SEGMENT_BYTES);
|
||||||
|
tail.drain(..overflow);
|
||||||
|
tail.extend(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::expect_used, clippy::panic)]
|
||||||
|
mod tests {
|
||||||
|
use super::{CapturedSegments, CapturedStream, STREAM_CAPTURE_BYTES, STREAM_SEGMENT_BYTES};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retains_complete_stream_at_exact_cap() {
|
||||||
|
let bytes = vec![b'a'; STREAM_CAPTURE_BYTES];
|
||||||
|
let capture = CapturedStream::drain(bytes.as_slice()).expect("capture");
|
||||||
|
assert!(!capture.truncated());
|
||||||
|
assert_eq!(capture.captured_bytes(), STREAM_CAPTURE_BYTES as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retains_exact_first_and_last_segments_over_cap() {
|
||||||
|
let mut bytes = vec![b'a'; STREAM_SEGMENT_BYTES];
|
||||||
|
bytes.extend(vec![b'm'; 17]);
|
||||||
|
bytes.extend(vec![b'z'; STREAM_SEGMENT_BYTES]);
|
||||||
|
let capture = CapturedStream::drain(bytes.as_slice()).expect("capture");
|
||||||
|
assert!(capture.truncated());
|
||||||
|
let CapturedSegments::Truncated {
|
||||||
|
first,
|
||||||
|
last,
|
||||||
|
last_offset,
|
||||||
|
} = capture.segments()
|
||||||
|
else {
|
||||||
|
panic!("expected split capture");
|
||||||
|
};
|
||||||
|
assert!(first.iter().all(|byte| *byte == b'a'));
|
||||||
|
assert!(last.iter().all(|byte| *byte == b'z'));
|
||||||
|
assert_eq!(last_offset, (STREAM_SEGMENT_BYTES + 17) as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utf8_status_covers_discarded_middle_bytes() {
|
||||||
|
let mut bytes = vec![b'a'; STREAM_SEGMENT_BYTES];
|
||||||
|
bytes.extend([0xff]);
|
||||||
|
bytes.extend(vec![b'z'; STREAM_SEGMENT_BYTES]);
|
||||||
|
let capture = CapturedStream::drain(bytes.as_slice()).expect("capture");
|
||||||
|
assert!(capture.truncated());
|
||||||
|
assert!(!capture.is_utf8());
|
||||||
|
}
|
||||||
|
}
|
||||||
369
src/process/diagnostics.rs
Normal file
369
src/process/diagnostics.rs
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
//! Diagnostic bundle metadata and store-neutral publication interface.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
fs::{self, File, OpenOptions},
|
||||||
|
io::{self, Write},
|
||||||
|
os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest as _, Sha256};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::{InvocationId, TaggedPath},
|
||||||
|
operation::Operation,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::capture::{CapturedSegments, CapturedStream};
|
||||||
|
|
||||||
|
/// Immutable publication result supplied by the Analysis Store layer.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PublishedDiagnostic {
|
||||||
|
/// Tagged absolute path to the published `manifest.json`.
|
||||||
|
pub manifest_path: TaggedPath,
|
||||||
|
/// Exact manifest file length.
|
||||||
|
pub bytes: u64,
|
||||||
|
/// SHA-256 of the exact manifest bytes.
|
||||||
|
pub sha256: String,
|
||||||
|
/// Diagnostic bundles may contain Sample-derived secrets.
|
||||||
|
pub sensitive: bool,
|
||||||
|
/// Always `application/json` for the descriptor target.
|
||||||
|
pub media_type: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store-owned publication seam; process code never chooses store layout.
|
||||||
|
pub trait DiagnosticPublisher {
|
||||||
|
/// Atomically publishes a fully captured private bundle.
|
||||||
|
fn publish(&self, bundle: DiagnosticBundle) -> io::Result<PublishedDiagnostic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Child termination class recorded in a diagnostic manifest.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TerminationReason {
|
||||||
|
/// Child exited and its response was accepted.
|
||||||
|
Success,
|
||||||
|
/// Child exited unsuccessfully.
|
||||||
|
ChildExit,
|
||||||
|
/// Child returned a complete typed adapter error.
|
||||||
|
AdapterError,
|
||||||
|
/// Derived watchdog expired.
|
||||||
|
WatchdogTimeout,
|
||||||
|
/// Caller cancellation won the result race.
|
||||||
|
Interrupted,
|
||||||
|
/// The child could not be launched.
|
||||||
|
SpawnFailure,
|
||||||
|
/// File protocol validation rejected the result.
|
||||||
|
ProtocolViolation,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether cancellation required SIGKILL after the grace period.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TerminationOutcome {
|
||||||
|
/// No signal escalation was necessary.
|
||||||
|
None,
|
||||||
|
/// The process group exited after SIGTERM.
|
||||||
|
Graceful,
|
||||||
|
/// SIGKILL was sent to the process group.
|
||||||
|
Forced,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata supplied by the lifecycle controller.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct InvocationDiagnosticMetadata {
|
||||||
|
/// Invocation identity.
|
||||||
|
pub invocation_id: InvocationId,
|
||||||
|
/// Worker operation.
|
||||||
|
pub operation: Operation,
|
||||||
|
/// Milliseconds since the Unix epoch when launch began.
|
||||||
|
pub started_unix_millis: u128,
|
||||||
|
/// Milliseconds since the Unix epoch after reap and drain.
|
||||||
|
pub finished_unix_millis: u128,
|
||||||
|
/// Why capture was retained.
|
||||||
|
pub termination_reason: TerminationReason,
|
||||||
|
/// Signal escalation outcome.
|
||||||
|
pub termination_outcome: TerminationOutcome,
|
||||||
|
/// Normal child exit code, when available.
|
||||||
|
pub child_exit_code: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A complete, bounded pair of drained child streams.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DiagnosticBundle {
|
||||||
|
metadata: InvocationDiagnosticMetadata,
|
||||||
|
stdout: CapturedStream,
|
||||||
|
stderr: CapturedStream,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiagnosticBundle {
|
||||||
|
/// Combines lifecycle metadata with concurrently drained child output.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(
|
||||||
|
metadata: InvocationDiagnosticMetadata,
|
||||||
|
stdout: CapturedStream,
|
||||||
|
stderr: CapturedStream,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
metadata,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether either stream omitted middle bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn truncated(&self) -> bool {
|
||||||
|
self.stdout.truncated() || self.stderr.truncated()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invocation identity used by a publisher to select its final path.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn invocation_id(&self) -> &InvocationId {
|
||||||
|
&self.metadata.invocation_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lifecycle reason recorded if the bundle is retained.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn termination_reason(&self) -> TerminationReason {
|
||||||
|
self.metadata.termination_reason
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a complete bundle into a new private directory.
|
||||||
|
///
|
||||||
|
/// The publisher remains responsible for atomically renaming this directory
|
||||||
|
/// into its durable store location.
|
||||||
|
pub fn write_private_directory(&self, directory: &Path) -> io::Result<PathBuf> {
|
||||||
|
let mut builder = fs::DirBuilder::new();
|
||||||
|
builder.mode(0o700);
|
||||||
|
builder.create(directory)?;
|
||||||
|
|
||||||
|
let (stdout_manifest, stdout_files) = stream_files("stdout", &self.stdout);
|
||||||
|
let (stderr_manifest, stderr_files) = stream_files("stderr", &self.stderr);
|
||||||
|
for file in stdout_files.into_iter().chain(stderr_files) {
|
||||||
|
write_new_file(&directory.join(&file.name), file.bytes)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest = DiagnosticManifest {
|
||||||
|
manifest_version: 1,
|
||||||
|
invocation_id: self.metadata.invocation_id.to_string(),
|
||||||
|
operation: self.metadata.operation,
|
||||||
|
started_unix_millis: self.metadata.started_unix_millis,
|
||||||
|
finished_unix_millis: self.metadata.finished_unix_millis,
|
||||||
|
termination_reason: self.metadata.termination_reason,
|
||||||
|
termination_outcome: self.metadata.termination_outcome,
|
||||||
|
child_exit_code: self.metadata.child_exit_code,
|
||||||
|
streams: BTreeMap::from([
|
||||||
|
("stderr".to_owned(), stderr_manifest),
|
||||||
|
("stdout".to_owned(), stdout_manifest),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
let mut bytes = serde_json::to_vec(&manifest).map_err(io::Error::other)?;
|
||||||
|
bytes.push(b'\n');
|
||||||
|
let path = directory.join("manifest.json");
|
||||||
|
write_new_file(&path, &bytes)?;
|
||||||
|
sync_directory(directory)?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA-256 hex digest for publication descriptors.
|
||||||
|
#[must_use]
|
||||||
|
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||||
|
hex::encode(Sha256::digest(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DiagnosticManifest {
|
||||||
|
manifest_version: u32,
|
||||||
|
invocation_id: String,
|
||||||
|
operation: Operation,
|
||||||
|
started_unix_millis: u128,
|
||||||
|
finished_unix_millis: u128,
|
||||||
|
termination_reason: TerminationReason,
|
||||||
|
termination_outcome: TerminationOutcome,
|
||||||
|
child_exit_code: Option<i32>,
|
||||||
|
streams: BTreeMap<String, StreamManifest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct StreamManifest {
|
||||||
|
total_bytes: u64,
|
||||||
|
captured_bytes: u64,
|
||||||
|
truncated: bool,
|
||||||
|
encoding: StreamEncoding,
|
||||||
|
segments: Vec<SegmentManifest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum StreamEncoding {
|
||||||
|
Utf8,
|
||||||
|
ArbitraryBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SegmentManifest {
|
||||||
|
offset: u64,
|
||||||
|
length: u64,
|
||||||
|
path: TaggedPath,
|
||||||
|
sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BundleFile<'a> {
|
||||||
|
name: String,
|
||||||
|
bytes: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_files<'a>(
|
||||||
|
stream_name: &str,
|
||||||
|
capture: &'a CapturedStream,
|
||||||
|
) -> (StreamManifest, Vec<BundleFile<'a>>) {
|
||||||
|
let encoding = if capture.is_utf8() {
|
||||||
|
StreamEncoding::Utf8
|
||||||
|
} else {
|
||||||
|
StreamEncoding::ArbitraryBytes
|
||||||
|
};
|
||||||
|
let (segments, files) = match capture.segments() {
|
||||||
|
CapturedSegments::Complete(bytes) => {
|
||||||
|
let name = format!("{stream_name}.log");
|
||||||
|
(
|
||||||
|
vec![segment(&name, 0, bytes)],
|
||||||
|
vec![BundleFile { name, bytes }],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CapturedSegments::Truncated {
|
||||||
|
first,
|
||||||
|
last,
|
||||||
|
last_offset,
|
||||||
|
} => {
|
||||||
|
let first_name = format!("{stream_name}.first.log");
|
||||||
|
let last_name = format!("{stream_name}.last.log");
|
||||||
|
(
|
||||||
|
vec![
|
||||||
|
segment(&first_name, 0, first),
|
||||||
|
segment(&last_name, last_offset, last),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
BundleFile {
|
||||||
|
name: first_name,
|
||||||
|
bytes: first,
|
||||||
|
},
|
||||||
|
BundleFile {
|
||||||
|
name: last_name,
|
||||||
|
bytes: last,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(
|
||||||
|
StreamManifest {
|
||||||
|
total_bytes: capture.total_bytes(),
|
||||||
|
captured_bytes: capture.captured_bytes(),
|
||||||
|
truncated: capture.truncated(),
|
||||||
|
encoding,
|
||||||
|
segments,
|
||||||
|
},
|
||||||
|
files,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment(name: &str, offset: u64, bytes: &[u8]) -> SegmentManifest {
|
||||||
|
SegmentManifest {
|
||||||
|
offset,
|
||||||
|
length: u64::try_from(bytes.len()).unwrap_or(u64::MAX),
|
||||||
|
path: TaggedPath::Utf8(name.to_owned()),
|
||||||
|
sha256: sha256_hex(bytes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_new_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(path)?;
|
||||||
|
file.write_all(bytes)?;
|
||||||
|
file.sync_all()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_directory(directory: &Path) -> io::Result<()> {
|
||||||
|
File::open(directory)?.sync_all()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::expect_used)]
|
||||||
|
mod tests {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
DiagnosticBundle, InvocationDiagnosticMetadata, TerminationOutcome, TerminationReason,
|
||||||
|
};
|
||||||
|
use crate::{
|
||||||
|
domain::InvocationId,
|
||||||
|
operation::Operation,
|
||||||
|
process::capture::{CapturedStream, STREAM_CAPTURE_BYTES},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn private_bundle_uses_exact_stream_bytes_and_private_modes() {
|
||||||
|
let root = tempdir().expect("temporary root");
|
||||||
|
let directory = root.path().join("bundle");
|
||||||
|
let bundle = DiagnosticBundle::new(
|
||||||
|
InvocationDiagnosticMetadata {
|
||||||
|
invocation_id: InvocationId::parse(&"a".repeat(32)).expect("identifier"),
|
||||||
|
operation: Operation::Doctor,
|
||||||
|
started_unix_millis: 1,
|
||||||
|
finished_unix_millis: 2,
|
||||||
|
termination_reason: TerminationReason::ChildExit,
|
||||||
|
termination_outcome: TerminationOutcome::None,
|
||||||
|
child_exit_code: Some(7),
|
||||||
|
},
|
||||||
|
CapturedStream::drain(&b"out\0"[..]).expect("stdout"),
|
||||||
|
CapturedStream::drain(&b"err\xff"[..]).expect("stderr"),
|
||||||
|
);
|
||||||
|
let manifest = bundle.write_private_directory(&directory).expect("bundle");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(directory.join("stdout.log")).expect("stdout"),
|
||||||
|
b"out\0"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(directory.join("stderr.log")).expect("stderr"),
|
||||||
|
b"err\xff"
|
||||||
|
);
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_slice(&fs::read(manifest).expect("manifest")).expect("json");
|
||||||
|
assert_eq!(value["streams"]["stderr"]["encoding"], "arbitrary_bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncated_bundle_uses_separate_segment_files() {
|
||||||
|
let root = tempdir().expect("temporary root");
|
||||||
|
let bytes = vec![b'x'; STREAM_CAPTURE_BYTES + 1];
|
||||||
|
let capture = CapturedStream::drain(bytes.as_slice()).expect("capture");
|
||||||
|
let bundle = DiagnosticBundle::new(
|
||||||
|
InvocationDiagnosticMetadata {
|
||||||
|
invocation_id: InvocationId::parse(&"b".repeat(32)).expect("identifier"),
|
||||||
|
operation: Operation::Inspect,
|
||||||
|
started_unix_millis: 1,
|
||||||
|
finished_unix_millis: 2,
|
||||||
|
termination_reason: TerminationReason::ProtocolViolation,
|
||||||
|
termination_outcome: TerminationOutcome::None,
|
||||||
|
child_exit_code: Some(0),
|
||||||
|
},
|
||||||
|
capture,
|
||||||
|
CapturedStream::drain(&b""[..]).expect("stderr"),
|
||||||
|
);
|
||||||
|
bundle
|
||||||
|
.write_private_directory(&root.path().join("bundle"))
|
||||||
|
.expect("bundle");
|
||||||
|
assert!(root.path().join("bundle/stdout.first.log").is_file());
|
||||||
|
assert!(root.path().join("bundle/stdout.last.log").is_file());
|
||||||
|
assert!(!root.path().join("bundle/stdout.log").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
616
src/process/lifecycle.rs
Normal file
616
src/process/lifecycle.rs
Normal file
|
|
@ -0,0 +1,616 @@
|
||||||
|
//! One-attempt synchronous worker lifecycle and process-group cancellation.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
io,
|
||||||
|
os::unix::process::CommandExt as _,
|
||||||
|
process::{Command, ExitStatus as ProcessExitStatus, Stdio},
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use rustix::process::{Pid, Signal, kill_process_group, test_kill_process_group};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
AppError, ErrorCode,
|
||||||
|
domain::PositiveU64,
|
||||||
|
protocol::{AdapterRequest, AdapterResponse, AdapterResult, InvocationFiles},
|
||||||
|
sandbox::WorkerCommand,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
capture::CapturedStream,
|
||||||
|
diagnostics::{
|
||||||
|
DiagnosticBundle, InvocationDiagnosticMetadata, TerminationOutcome, TerminationReason,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Fixed time after SIGTERM before forced process-group termination.
|
||||||
|
pub const TERMINATION_GRACE: Duration = Duration::from_secs(10);
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_millis(10);
|
||||||
|
const WATCHDOG_OVERHEAD_SECONDS: u64 = 120;
|
||||||
|
|
||||||
|
/// Pollable caller cancellation state.
|
||||||
|
///
|
||||||
|
/// A value of one requests graceful interruption; two or more immediately
|
||||||
|
/// escalates an active grace period to SIGKILL.
|
||||||
|
pub trait Cancellation {
|
||||||
|
/// Number of interrupt requests observed so far.
|
||||||
|
fn interrupt_count(&self) -> u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production default when no frontend cancellation adapter is installed.
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
pub struct NeverCancel;
|
||||||
|
|
||||||
|
impl Cancellation for NeverCancel {
|
||||||
|
fn interrupt_count(&self) -> u32 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enabled native phases used to derive, never independently configure, a watchdog.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct NativePhaseTimeouts {
|
||||||
|
/// Auto-analysis phase, absent for a compatible cached Analysis.
|
||||||
|
pub analysis_seconds: Option<PositiveU64>,
|
||||||
|
/// Targeted decompilation phase, absent for non-decompilation Queries.
|
||||||
|
pub decompile_seconds: Option<PositiveU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativePhaseTimeouts {
|
||||||
|
/// Sum of enabled phase bounds plus the fixed 120-second harness overhead.
|
||||||
|
pub fn watchdog(self) -> Result<Duration, WatchdogError> {
|
||||||
|
let seconds = self
|
||||||
|
.analysis_seconds
|
||||||
|
.map_or(0, PositiveU64::get)
|
||||||
|
.checked_add(self.decompile_seconds.map_or(0, PositiveU64::get))
|
||||||
|
.and_then(|value| value.checked_add(WATCHDOG_OVERHEAD_SECONDS))
|
||||||
|
.ok_or(WatchdogError::Overflow)?;
|
||||||
|
Ok(Duration::from_secs(seconds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derived watchdog construction failure.
|
||||||
|
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
|
||||||
|
pub enum WatchdogError {
|
||||||
|
/// Selected phase bounds cannot be represented after addition.
|
||||||
|
#[error("derived child watchdog overflows the supported duration")]
|
||||||
|
Overflow,
|
||||||
|
/// Protocol provenance did not echo the automatically derived value.
|
||||||
|
#[error("request child watchdog does not match enabled native phases")]
|
||||||
|
RequestMismatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inputs for exactly one worker attempt.
|
||||||
|
pub struct Invocation<'a> {
|
||||||
|
/// Strict request written before launch.
|
||||||
|
request: &'a AdapterRequest,
|
||||||
|
/// Existing private file-protocol directory.
|
||||||
|
files: &'a InvocationFiles,
|
||||||
|
/// Fully resolved direct or sandboxed command.
|
||||||
|
command: &'a WorkerCommand,
|
||||||
|
/// Automatically derived hard watchdog.
|
||||||
|
watchdog: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Invocation<'a> {
|
||||||
|
/// Creates an Invocation only when limit provenance matches the derived watchdog.
|
||||||
|
pub fn new(
|
||||||
|
request: &'a AdapterRequest,
|
||||||
|
files: &'a InvocationFiles,
|
||||||
|
command: &'a WorkerCommand,
|
||||||
|
phases: NativePhaseTimeouts,
|
||||||
|
) -> Result<Self, WatchdogError> {
|
||||||
|
let watchdog = phases.watchdog()?;
|
||||||
|
if request.limits.child_watchdog_seconds.get() != watchdog.as_secs() {
|
||||||
|
return Err(WatchdogError::RequestMismatch);
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
request,
|
||||||
|
files,
|
||||||
|
command,
|
||||||
|
watchdog,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the production watchdog only in debug builds for fast lifecycle tests.
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_test_watchdog(mut self, watchdog: Duration) -> Self {
|
||||||
|
self.watchdog = watchdog;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A complete successful transport attempt.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CompletedInvocation {
|
||||||
|
/// Strict validated adapter response.
|
||||||
|
pub response: AdapterResponse,
|
||||||
|
/// Captured streams, normally dropped unless a success warning references them.
|
||||||
|
pub diagnostics: DiagnosticBundle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompletedInvocation {
|
||||||
|
/// Typed adapter errors always require durable diagnostic publication.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn requires_diagnostic_publication(&self) -> bool {
|
||||||
|
matches!(&self.response.result, AdapterResult::Error { .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure category with stable public error mapping.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum InvocationFailureKind {
|
||||||
|
/// Request file construction failed before launch.
|
||||||
|
RequestProtocol,
|
||||||
|
/// OS process creation failed.
|
||||||
|
Spawn,
|
||||||
|
/// Derived watchdog expired.
|
||||||
|
WatchdogTimeout,
|
||||||
|
/// Caller interruption won the result race.
|
||||||
|
Interrupted,
|
||||||
|
/// Child exited unsuccessfully.
|
||||||
|
ChildExit,
|
||||||
|
/// Response file or typed response validation failed.
|
||||||
|
ResponseProtocol,
|
||||||
|
/// Concurrent stream drain failed.
|
||||||
|
DiagnosticDrain,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InvocationFailureKind {
|
||||||
|
/// Stable public code used by command orchestration.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn error_code(self) -> ErrorCode {
|
||||||
|
match self {
|
||||||
|
Self::WatchdogTimeout => ErrorCode::Timeout,
|
||||||
|
Self::Interrupted => ErrorCode::Interrupted,
|
||||||
|
Self::RequestProtocol | Self::ResponseProtocol => ErrorCode::ProtocolViolation,
|
||||||
|
Self::Spawn | Self::ChildExit | Self::DiagnosticDrain => ErrorCode::Internal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failed transport attempt with retained diagnostic material.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[error("{message}")]
|
||||||
|
pub struct InvocationFailure {
|
||||||
|
/// Stable lifecycle classification.
|
||||||
|
pub kind: InvocationFailureKind,
|
||||||
|
/// Concise internal context.
|
||||||
|
pub message: String,
|
||||||
|
/// Captured material to publish atomically.
|
||||||
|
pub diagnostics: Box<DiagnosticBundle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InvocationFailure {
|
||||||
|
/// Converts lifecycle classification to the public error taxonomy.
|
||||||
|
#[must_use]
|
||||||
|
pub fn app_error(&self) -> AppError {
|
||||||
|
AppError::new(self.kind.error_code(), self.message.clone(), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs one and only one worker process attempt.
|
||||||
|
pub fn run(
|
||||||
|
invocation: Invocation<'_>,
|
||||||
|
cancellation: &impl Cancellation,
|
||||||
|
) -> Result<CompletedInvocation, InvocationFailure> {
|
||||||
|
let started_unix_millis = unix_millis();
|
||||||
|
if let Err(error) = invocation.files.write_request(invocation.request) {
|
||||||
|
return Err(failure_without_child(
|
||||||
|
invocation.request,
|
||||||
|
started_unix_millis,
|
||||||
|
InvocationFailureKind::RequestProtocol,
|
||||||
|
TerminationReason::ProtocolViolation,
|
||||||
|
error.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut command = Command::new(&invocation.command.program);
|
||||||
|
command
|
||||||
|
.args(&invocation.command.arguments)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.process_group(0);
|
||||||
|
let mut child = match command.spawn() {
|
||||||
|
Ok(child) => child,
|
||||||
|
Err(error) => {
|
||||||
|
return Err(failure_without_child(
|
||||||
|
invocation.request,
|
||||||
|
started_unix_millis,
|
||||||
|
InvocationFailureKind::Spawn,
|
||||||
|
TerminationReason::SpawnFailure,
|
||||||
|
format!("worker launch failed: {error}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let stdout = child.stdout.take();
|
||||||
|
let stderr = child.stderr.take();
|
||||||
|
let stdout_thread = stdout.map(|pipe| thread::spawn(move || CapturedStream::drain(pipe)));
|
||||||
|
let stderr_thread = stderr.map(|pipe| thread::spawn(move || CapturedStream::drain(pipe)));
|
||||||
|
let process_group = Pid::from_raw(i32::try_from(child.id()).unwrap_or(i32::MAX));
|
||||||
|
let deadline = Instant::now().checked_add(invocation.watchdog);
|
||||||
|
|
||||||
|
let mut cancellation_reason = None;
|
||||||
|
let status = loop {
|
||||||
|
if cancellation.interrupt_count() > 0 {
|
||||||
|
cancellation_reason = Some(TerminationReason::Interrupted);
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
if deadline.is_none_or(|value| Instant::now() >= value) {
|
||||||
|
cancellation_reason = Some(TerminationReason::WatchdogTimeout);
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(status)) => break Some(status),
|
||||||
|
Ok(None) => thread::sleep(POLL_INTERVAL),
|
||||||
|
Err(_error) => {
|
||||||
|
cancellation_reason = Some(TerminationReason::ChildExit);
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while cancellation_reason.is_none()
|
||||||
|
&& !drains_finished(stdout_thread.as_ref(), stderr_thread.as_ref())
|
||||||
|
{
|
||||||
|
if cancellation.interrupt_count() > 0 {
|
||||||
|
cancellation_reason = Some(TerminationReason::Interrupted);
|
||||||
|
} else if deadline.is_none_or(|value| Instant::now() >= value) {
|
||||||
|
cancellation_reason = Some(TerminationReason::WatchdogTimeout);
|
||||||
|
} else {
|
||||||
|
thread::sleep(POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (status, termination_outcome) = if let Some(reason) = cancellation_reason {
|
||||||
|
let (status, outcome) = terminate_group(
|
||||||
|
&mut child,
|
||||||
|
process_group,
|
||||||
|
cancellation,
|
||||||
|
stdout_thread.as_ref(),
|
||||||
|
stderr_thread.as_ref(),
|
||||||
|
);
|
||||||
|
let failure_kind = if reason == TerminationReason::Interrupted {
|
||||||
|
InvocationFailureKind::Interrupted
|
||||||
|
} else if reason == TerminationReason::WatchdogTimeout {
|
||||||
|
InvocationFailureKind::WatchdogTimeout
|
||||||
|
} else {
|
||||||
|
InvocationFailureKind::ChildExit
|
||||||
|
};
|
||||||
|
let (stdout, stderr, drain_error) = join_captures(stdout_thread, stderr_thread);
|
||||||
|
let diagnostics = diagnostic_bundle(
|
||||||
|
invocation.request,
|
||||||
|
started_unix_millis,
|
||||||
|
reason,
|
||||||
|
outcome,
|
||||||
|
status.as_ref(),
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
);
|
||||||
|
return Err(InvocationFailure {
|
||||||
|
kind: failure_kind,
|
||||||
|
message: drain_error.unwrap_or_else(|| match reason {
|
||||||
|
TerminationReason::Interrupted => "worker invocation was interrupted".to_owned(),
|
||||||
|
TerminationReason::WatchdogTimeout => {
|
||||||
|
"worker exceeded the derived watchdog".to_owned()
|
||||||
|
}
|
||||||
|
_ => "worker status could not be observed".to_owned(),
|
||||||
|
}),
|
||||||
|
diagnostics,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
(status, TerminationOutcome::None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (stdout, stderr, drain_error) = join_captures(stdout_thread, stderr_thread);
|
||||||
|
if let Some(message) = drain_error {
|
||||||
|
return Err(InvocationFailure {
|
||||||
|
kind: InvocationFailureKind::DiagnosticDrain,
|
||||||
|
message,
|
||||||
|
diagnostics: diagnostic_bundle(
|
||||||
|
invocation.request,
|
||||||
|
started_unix_millis,
|
||||||
|
TerminationReason::ProtocolViolation,
|
||||||
|
termination_outcome,
|
||||||
|
status.as_ref(),
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let Some(status) = status else {
|
||||||
|
return Err(InvocationFailure {
|
||||||
|
kind: InvocationFailureKind::ChildExit,
|
||||||
|
message: "worker exited without an observable status".to_owned(),
|
||||||
|
diagnostics: diagnostic_bundle(
|
||||||
|
invocation.request,
|
||||||
|
started_unix_millis,
|
||||||
|
TerminationReason::ChildExit,
|
||||||
|
termination_outcome,
|
||||||
|
None,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if cancellation.interrupt_count() > 0 {
|
||||||
|
return Err(InvocationFailure {
|
||||||
|
kind: InvocationFailureKind::Interrupted,
|
||||||
|
message: "worker invocation was interrupted".to_owned(),
|
||||||
|
diagnostics: diagnostic_bundle(
|
||||||
|
invocation.request,
|
||||||
|
started_unix_millis,
|
||||||
|
TerminationReason::Interrupted,
|
||||||
|
TerminationOutcome::Graceful,
|
||||||
|
Some(&status),
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !status.success() {
|
||||||
|
return Err(InvocationFailure {
|
||||||
|
kind: InvocationFailureKind::ChildExit,
|
||||||
|
message: format!("worker exited unsuccessfully: {status}"),
|
||||||
|
diagnostics: diagnostic_bundle(
|
||||||
|
invocation.request,
|
||||||
|
started_unix_millis,
|
||||||
|
TerminationReason::ChildExit,
|
||||||
|
termination_outcome,
|
||||||
|
Some(&status),
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if process_group.is_some_and(group_is_alive) {
|
||||||
|
let outcome = terminate_lingering_group(process_group, cancellation);
|
||||||
|
return Err(InvocationFailure {
|
||||||
|
kind: InvocationFailureKind::ChildExit,
|
||||||
|
message: "worker leader exited while its process group remained alive".to_owned(),
|
||||||
|
diagnostics: diagnostic_bundle(
|
||||||
|
invocation.request,
|
||||||
|
started_unix_millis,
|
||||||
|
TerminationReason::ChildExit,
|
||||||
|
outcome,
|
||||||
|
Some(&status),
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
match invocation.files.read_response(invocation.request) {
|
||||||
|
Ok(response) => {
|
||||||
|
let interrupted = cancellation.interrupt_count() > 0;
|
||||||
|
let adapter_error = matches!(&response.result, AdapterResult::Error { .. });
|
||||||
|
let diagnostics = diagnostic_bundle(
|
||||||
|
invocation.request,
|
||||||
|
started_unix_millis,
|
||||||
|
if interrupted {
|
||||||
|
TerminationReason::Interrupted
|
||||||
|
} else if adapter_error {
|
||||||
|
TerminationReason::AdapterError
|
||||||
|
} else {
|
||||||
|
TerminationReason::Success
|
||||||
|
},
|
||||||
|
termination_outcome,
|
||||||
|
Some(&status),
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
);
|
||||||
|
if interrupted {
|
||||||
|
Err(InvocationFailure {
|
||||||
|
kind: InvocationFailureKind::Interrupted,
|
||||||
|
message: "worker invocation was interrupted".to_owned(),
|
||||||
|
diagnostics,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Ok(CompletedInvocation {
|
||||||
|
response,
|
||||||
|
diagnostics: *diagnostics,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => Err(InvocationFailure {
|
||||||
|
kind: InvocationFailureKind::ResponseProtocol,
|
||||||
|
message: format!("worker response violated the file protocol: {error}"),
|
||||||
|
diagnostics: diagnostic_bundle(
|
||||||
|
invocation.request,
|
||||||
|
started_unix_millis,
|
||||||
|
TerminationReason::ProtocolViolation,
|
||||||
|
termination_outcome,
|
||||||
|
Some(&status),
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminate_group(
|
||||||
|
child: &mut std::process::Child,
|
||||||
|
process_group: Option<Pid>,
|
||||||
|
cancellation: &impl Cancellation,
|
||||||
|
stdout: Option<&DrainThread>,
|
||||||
|
stderr: Option<&DrainThread>,
|
||||||
|
) -> (Option<ProcessExitStatus>, TerminationOutcome) {
|
||||||
|
signal_group(process_group, Signal::TERM);
|
||||||
|
let deadline = Instant::now() + TERMINATION_GRACE;
|
||||||
|
let mut status = None;
|
||||||
|
loop {
|
||||||
|
if cancellation.interrupt_count() > 1 || Instant::now() >= deadline {
|
||||||
|
signal_group(process_group, Signal::KILL);
|
||||||
|
return (
|
||||||
|
status.or_else(|| child.wait().ok()),
|
||||||
|
TerminationOutcome::Forced,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if status.is_none() {
|
||||||
|
status = match child.try_wait() {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => child.wait().ok(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if status.is_some()
|
||||||
|
&& drains_finished(stdout, stderr)
|
||||||
|
&& process_group.is_none_or(|group| !group_is_alive(group))
|
||||||
|
{
|
||||||
|
return (status, TerminationOutcome::Graceful);
|
||||||
|
}
|
||||||
|
thread::sleep(POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminate_lingering_group(
|
||||||
|
process_group: Option<Pid>,
|
||||||
|
cancellation: &impl Cancellation,
|
||||||
|
) -> TerminationOutcome {
|
||||||
|
signal_group(process_group, Signal::TERM);
|
||||||
|
let deadline = Instant::now() + TERMINATION_GRACE;
|
||||||
|
while process_group.is_some_and(group_is_alive) {
|
||||||
|
if cancellation.interrupt_count() > 1 || Instant::now() >= deadline {
|
||||||
|
signal_group(process_group, Signal::KILL);
|
||||||
|
return TerminationOutcome::Forced;
|
||||||
|
}
|
||||||
|
thread::sleep(POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
TerminationOutcome::Graceful
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_is_alive(process_group: Pid) -> bool {
|
||||||
|
test_kill_process_group(process_group).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signal_group(process_group: Option<Pid>, signal: Signal) {
|
||||||
|
if let Some(process_group) = process_group {
|
||||||
|
let _ignored = kill_process_group(process_group, signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrainThread = thread::JoinHandle<io::Result<CapturedStream>>;
|
||||||
|
|
||||||
|
fn drains_finished(stdout: Option<&DrainThread>, stderr: Option<&DrainThread>) -> bool {
|
||||||
|
stdout.is_none_or(thread::JoinHandle::is_finished)
|
||||||
|
&& stderr.is_none_or(thread::JoinHandle::is_finished)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_captures(
|
||||||
|
stdout: Option<DrainThread>,
|
||||||
|
stderr: Option<DrainThread>,
|
||||||
|
) -> (CapturedStream, CapturedStream, Option<String>) {
|
||||||
|
let (stdout, stdout_error) = join_capture(stdout, "stdout");
|
||||||
|
let (stderr, stderr_error) = join_capture(stderr, "stderr");
|
||||||
|
(stdout, stderr, stdout_error.or(stderr_error))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_capture(thread: Option<DrainThread>, name: &str) -> (CapturedStream, Option<String>) {
|
||||||
|
let Some(thread) = thread else {
|
||||||
|
return (
|
||||||
|
CapturedStream::empty(),
|
||||||
|
Some(format!("worker {name} pipe was unavailable")),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
match thread.join() {
|
||||||
|
Ok(Ok(capture)) => (capture, None),
|
||||||
|
Ok(Err(error)) => (
|
||||||
|
CapturedStream::empty(),
|
||||||
|
Some(format!("worker {name} drain failed: {error}")),
|
||||||
|
),
|
||||||
|
Err(_) => (
|
||||||
|
CapturedStream::empty(),
|
||||||
|
Some(format!("worker {name} drain thread failed")),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failure_without_child(
|
||||||
|
request: &AdapterRequest,
|
||||||
|
started_unix_millis: u128,
|
||||||
|
kind: InvocationFailureKind,
|
||||||
|
reason: TerminationReason,
|
||||||
|
message: String,
|
||||||
|
) -> InvocationFailure {
|
||||||
|
InvocationFailure {
|
||||||
|
kind,
|
||||||
|
message,
|
||||||
|
diagnostics: diagnostic_bundle(
|
||||||
|
request,
|
||||||
|
started_unix_millis,
|
||||||
|
reason,
|
||||||
|
TerminationOutcome::None,
|
||||||
|
None,
|
||||||
|
CapturedStream::empty(),
|
||||||
|
CapturedStream::empty(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnostic_bundle(
|
||||||
|
request: &AdapterRequest,
|
||||||
|
started_unix_millis: u128,
|
||||||
|
termination_reason: TerminationReason,
|
||||||
|
termination_outcome: TerminationOutcome,
|
||||||
|
status: Option<&ProcessExitStatus>,
|
||||||
|
stdout: CapturedStream,
|
||||||
|
stderr: CapturedStream,
|
||||||
|
) -> Box<DiagnosticBundle> {
|
||||||
|
Box::new(DiagnosticBundle::new(
|
||||||
|
InvocationDiagnosticMetadata {
|
||||||
|
invocation_id: request.invocation_id.clone(),
|
||||||
|
operation: request.operation,
|
||||||
|
started_unix_millis,
|
||||||
|
finished_unix_millis: unix_millis(),
|
||||||
|
termination_reason,
|
||||||
|
termination_outcome,
|
||||||
|
child_exit_code: status.and_then(ProcessExitStatus::code),
|
||||||
|
},
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_millis() -> u128 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or(Duration::ZERO)
|
||||||
|
.as_millis()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::expect_used)]
|
||||||
|
mod tests {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::NativePhaseTimeouts;
|
||||||
|
use crate::domain::PositiveU64;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derives_watchdog_from_enabled_native_phases() {
|
||||||
|
let analysis = PositiveU64::try_from(600).expect("positive");
|
||||||
|
let decompile = PositiveU64::try_from(60).expect("positive");
|
||||||
|
assert_eq!(
|
||||||
|
NativePhaseTimeouts {
|
||||||
|
analysis_seconds: Some(analysis),
|
||||||
|
decompile_seconds: Some(decompile)
|
||||||
|
}
|
||||||
|
.watchdog()
|
||||||
|
.expect("duration"),
|
||||||
|
Duration::from_secs(780),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
NativePhaseTimeouts {
|
||||||
|
analysis_seconds: None,
|
||||||
|
decompile_seconds: None
|
||||||
|
}
|
||||||
|
.watchdog()
|
||||||
|
.expect("duration"),
|
||||||
|
Duration::from_secs(120),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/process/mod.rs
Normal file
10
src/process/mod.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
//! Synchronous worker lifecycle, bounded diagnostics, and publication seams.
|
||||||
|
|
||||||
|
pub mod capture;
|
||||||
|
pub mod diagnostics;
|
||||||
|
mod lifecycle;
|
||||||
|
|
||||||
|
pub use lifecycle::{
|
||||||
|
Cancellation, CompletedInvocation, Invocation, InvocationFailure, InvocationFailureKind,
|
||||||
|
NativePhaseTimeouts, NeverCancel, TERMINATION_GRACE, WatchdogError, run,
|
||||||
|
};
|
||||||
230
src/protocol.rs
230
src/protocol.rs
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
ffi::OsStr,
|
ffi::OsStr,
|
||||||
io::{self, Read},
|
fs::{self, File, OpenOptions},
|
||||||
|
io::{self, Read, Write},
|
||||||
|
os::unix::fs::OpenOptionsExt as _,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -12,7 +14,10 @@ use thiserror::Error;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::{FunctionSelector, InvocationId, Limits, PageRequest},
|
domain::{FunctionSelector, InvocationId, Limits, PageRequest},
|
||||||
operation::{ADAPTER_PROTOCOL_VERSION, Operation},
|
operation::{
|
||||||
|
ADAPTER_PROTOCOL_VERSION, CleanupData, DecompilationData, DoctorData, FunctionsData,
|
||||||
|
InspectionData, Operation,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Maximum exact serialized `request.json` size (1 MiB).
|
/// Maximum exact serialized `request.json` size (1 MiB).
|
||||||
|
|
@ -20,6 +25,13 @@ pub const MAX_REQUEST_BYTES: usize = 1_048_576;
|
||||||
/// Maximum exact serialized `response.json` size (256 MiB).
|
/// Maximum exact serialized `response.json` size (256 MiB).
|
||||||
pub const MAX_RESPONSE_BYTES: usize = 268_435_456;
|
pub const MAX_RESPONSE_BYTES: usize = 268_435_456;
|
||||||
|
|
||||||
|
/// Fixed names inside one private invocation directory.
|
||||||
|
pub const REQUEST_FILE_NAME: &str = "request.json";
|
||||||
|
/// Java's incomplete response, never accepted by Rust.
|
||||||
|
pub const TEMP_RESPONSE_FILE_NAME: &str = "response.json.tmp";
|
||||||
|
/// Java's only accepted final response name.
|
||||||
|
pub const RESPONSE_FILE_NAME: &str = "response.json";
|
||||||
|
|
||||||
/// A validated UTF-8 absolute path owned by the trusted harness.
|
/// A validated UTF-8 absolute path owned by the trusted harness.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
|
|
@ -164,6 +176,129 @@ pub fn validate_response_echo(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Strictly decoded operation data after echo and schema validation.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ValidatedAdapterData {
|
||||||
|
/// Installation probe result.
|
||||||
|
Doctor(DoctorData),
|
||||||
|
/// Program inspection result.
|
||||||
|
Inspect(InspectionData),
|
||||||
|
/// Stable Function page.
|
||||||
|
Functions(FunctionsData),
|
||||||
|
/// Complete decompilation.
|
||||||
|
Decompile(DecompilationData),
|
||||||
|
/// Cleanup parity result; normal cleanup remains in trusted Rust.
|
||||||
|
Clean(CleanupData),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a success payload against the exact operation-specific Rust type.
|
||||||
|
///
|
||||||
|
/// Comparing the value with its typed round trip also rejects unknown fields,
|
||||||
|
/// rather than silently discarding them during deserialization.
|
||||||
|
pub fn validate_operation_data(
|
||||||
|
operation: Operation,
|
||||||
|
data: serde_json::Value,
|
||||||
|
) -> Result<ValidatedAdapterData, ProtocolCodecError> {
|
||||||
|
macro_rules! exact {
|
||||||
|
($type:ty, $variant:ident) => {{
|
||||||
|
let decoded: $type = serde_json::from_value(data.clone())?;
|
||||||
|
if serde_json::to_value(&decoded)? != data {
|
||||||
|
return Err(ProtocolCodecError::OperationSchemaMismatch);
|
||||||
|
}
|
||||||
|
Ok(ValidatedAdapterData::$variant(decoded))
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
match operation {
|
||||||
|
Operation::Doctor => exact!(DoctorData, Doctor),
|
||||||
|
Operation::Inspect => exact!(InspectionData, Inspect),
|
||||||
|
Operation::Functions => exact!(FunctionsData, Functions),
|
||||||
|
Operation::Decompile => exact!(DecompilationData, Decompile),
|
||||||
|
Operation::Clean => exact!(CleanupData, Clean),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File paths and atomic request/strict response handling for one Invocation.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct InvocationFiles {
|
||||||
|
directory: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InvocationFiles {
|
||||||
|
/// Uses an existing private, tool-owned invocation directory.
|
||||||
|
pub fn new(directory: PathBuf) -> Result<Self, ProtocolCodecError> {
|
||||||
|
if !directory.is_absolute() || directory.to_str().is_none() {
|
||||||
|
return Err(ProtocolCodecError::InvalidWorkerPath);
|
||||||
|
}
|
||||||
|
let metadata = fs::symlink_metadata(&directory)?;
|
||||||
|
if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() {
|
||||||
|
return Err(ProtocolCodecError::InvalidInvocationDirectory);
|
||||||
|
}
|
||||||
|
Ok(Self { directory })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute request path passed to the adapter.
|
||||||
|
#[must_use]
|
||||||
|
pub fn request_path(&self) -> PathBuf {
|
||||||
|
self.directory.join(REQUEST_FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute final response path passed to the adapter.
|
||||||
|
#[must_use]
|
||||||
|
pub fn response_path(&self) -> PathBuf {
|
||||||
|
self.directory.join(RESPONSE_FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically creates a mode-0600 request before worker launch.
|
||||||
|
pub fn write_request(&self, request: &AdapterRequest) -> Result<(), ProtocolCodecError> {
|
||||||
|
let bytes = encode_request(request)?;
|
||||||
|
let temporary = self.directory.join("request.json.tmp");
|
||||||
|
let final_path = self.request_path();
|
||||||
|
if fs::symlink_metadata(&final_path).is_ok() || fs::symlink_metadata(&temporary).is_ok() {
|
||||||
|
return Err(ProtocolCodecError::ProtocolFileAlreadyExists);
|
||||||
|
}
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&temporary)?;
|
||||||
|
file.write_all(&bytes)?;
|
||||||
|
file.sync_all()?;
|
||||||
|
fs::rename(&temporary, &final_path)?;
|
||||||
|
File::open(&self.directory)?.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens only a final regular response, then checks bounds and echoed fields.
|
||||||
|
pub fn read_response(
|
||||||
|
&self,
|
||||||
|
request: &AdapterRequest,
|
||||||
|
) -> Result<AdapterResponse, ProtocolCodecError> {
|
||||||
|
let path = self.response_path();
|
||||||
|
let metadata = fs::symlink_metadata(&path).map_err(|error| {
|
||||||
|
if error.kind() == io::ErrorKind::NotFound {
|
||||||
|
ProtocolCodecError::MissingFinalResponse
|
||||||
|
} else {
|
||||||
|
ProtocolCodecError::Io(error)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
|
||||||
|
return Err(ProtocolCodecError::InvalidFinalResponseType);
|
||||||
|
}
|
||||||
|
if metadata.len() > MAX_RESPONSE_BYTES as u64 {
|
||||||
|
return Err(ProtocolCodecError::ResponseTooLarge {
|
||||||
|
limit: MAX_RESPONSE_BYTES,
|
||||||
|
observed: MAX_RESPONSE_BYTES.saturating_add(1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let response = decode_response(File::open(path)?)?;
|
||||||
|
validate_response_echo(request, &response)?;
|
||||||
|
if let AdapterResult::Success { data } = &response.result {
|
||||||
|
let _validated = validate_operation_data(response.operation, data.clone())?;
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn encode_bounded<T: Serialize>(
|
fn encode_bounded<T: Serialize>(
|
||||||
value: &T,
|
value: &T,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
|
|
@ -238,6 +373,21 @@ pub enum ProtocolCodecError {
|
||||||
/// Operation echo differs.
|
/// Operation echo differs.
|
||||||
#[error("adapter operation mismatch")]
|
#[error("adapter operation mismatch")]
|
||||||
OperationMismatch,
|
OperationMismatch,
|
||||||
|
/// Operation data did not round-trip through its exact typed schema.
|
||||||
|
#[error("adapter operation data failed strict schema validation")]
|
||||||
|
OperationSchemaMismatch,
|
||||||
|
/// The private invocation directory is not a real directory.
|
||||||
|
#[error("invocation path must name a real directory")]
|
||||||
|
InvalidInvocationDirectory,
|
||||||
|
/// A protocol path unexpectedly existed before launch.
|
||||||
|
#[error("protocol file already exists before worker launch")]
|
||||||
|
ProtocolFileAlreadyExists,
|
||||||
|
/// No atomically published final response exists.
|
||||||
|
#[error("adapter did not publish response.json")]
|
||||||
|
MissingFinalResponse,
|
||||||
|
/// The final response is a symlink, directory, or special file.
|
||||||
|
#[error("response.json must be a regular non-symlink file")]
|
||||||
|
InvalidFinalResponseType,
|
||||||
/// Platform cannot represent the protocol bound.
|
/// Platform cannot represent the protocol bound.
|
||||||
#[error("platform cannot represent protocol size bound")]
|
#[error("platform cannot represent protocol size bound")]
|
||||||
PlatformLimit,
|
PlatformLimit,
|
||||||
|
|
@ -260,10 +410,45 @@ pub fn requests_human_format(arguments: &[impl AsRef<OsStr>]) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::expect_used)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::io::Cursor;
|
use std::{io::Cursor, path::PathBuf};
|
||||||
|
|
||||||
use super::{ProtocolCodecError, decode_bounded, requests_human_format};
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
AdapterRequest, InvocationFiles, ProtocolCodecError, RequestArguments, decode_bounded,
|
||||||
|
requests_human_format, validate_operation_data,
|
||||||
|
};
|
||||||
|
use crate::{
|
||||||
|
domain::{FunctionSelector, InvocationId, Limits, PositiveU64},
|
||||||
|
operation::{ADAPTER_PROTOCOL_VERSION, Operation},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn positive(value: u64) -> PositiveU64 {
|
||||||
|
PositiveU64::try_from(value).expect("positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(arguments: RequestArguments) -> AdapterRequest {
|
||||||
|
AdapterRequest {
|
||||||
|
protocol_version: ADAPTER_PROTOCOL_VERSION,
|
||||||
|
invocation_id: InvocationId::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||||
|
.expect("identifier"),
|
||||||
|
operation: arguments.operation(),
|
||||||
|
staged_sample: None,
|
||||||
|
analysis_path: None,
|
||||||
|
limits: Limits {
|
||||||
|
max_heap_mib: positive(2048),
|
||||||
|
max_cpu: positive(2),
|
||||||
|
analysis_timeout_seconds: positive(600),
|
||||||
|
decompile_timeout_seconds: None,
|
||||||
|
child_watchdog_seconds: positive(120),
|
||||||
|
max_sample_bytes: positive(1_073_741_824),
|
||||||
|
max_inline_bytes: positive(65_536),
|
||||||
|
},
|
||||||
|
arguments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn response_reader_stops_at_bound_plus_one() {
|
fn response_reader_stops_at_bound_plus_one() {
|
||||||
|
|
@ -302,4 +487,41 @@ mod tests {
|
||||||
Err(ProtocolCodecError::RequestTooLarge { .. })
|
Err(ProtocolCodecError::RequestTooLarge { .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oversized_typed_request_is_rejected_before_any_file_is_published() {
|
||||||
|
let root = tempdir().expect("temporary root");
|
||||||
|
let invocation = root.path().join("invocation");
|
||||||
|
std::fs::create_dir(&invocation).expect("invocation directory");
|
||||||
|
let files = InvocationFiles::new(invocation).expect("protocol paths");
|
||||||
|
let request = request(RequestArguments::Decompile {
|
||||||
|
selector: FunctionSelector::Name("x".repeat(super::MAX_REQUEST_BYTES)),
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
files.write_request(&request),
|
||||||
|
Err(ProtocolCodecError::RequestTooLarge { .. })
|
||||||
|
));
|
||||||
|
assert!(!files.request_path().exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operation_schema_rejects_unknown_fields() {
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"ready": true,
|
||||||
|
"components": {},
|
||||||
|
"unexpected": true
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
validate_operation_data(Operation::Doctor, data),
|
||||||
|
Err(ProtocolCodecError::OperationSchemaMismatch)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invocation_directory_must_be_absolute() {
|
||||||
|
assert!(matches!(
|
||||||
|
InvocationFiles::new(PathBuf::from("relative")),
|
||||||
|
Err(ProtocolCodecError::InvalidWorkerPath)
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
303
src/sandbox/mod.rs
Normal file
303
src/sandbox/mod.rs
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
//! Explicit worker sandbox policy and production Bubblewrap command construction.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
ffi::{OsStr, OsString},
|
||||||
|
path::{Component, Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
cli::SandboxMode,
|
||||||
|
operation::{SandboxBackend, SandboxProvenance, SandboxVerification, Warning},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A fully resolved worker command without a shell or caller-supplied runner.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct WorkerCommand {
|
||||||
|
/// Executable launched by trusted Rust.
|
||||||
|
pub program: PathBuf,
|
||||||
|
/// Exact argument vector.
|
||||||
|
pub arguments: Vec<OsString>,
|
||||||
|
/// Public backend provenance.
|
||||||
|
pub provenance: SandboxProvenance,
|
||||||
|
/// Mandatory warning for external and disabled policies.
|
||||||
|
pub warnings: Vec<Warning>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inputs whose capabilities are exposed to one Bubblewrap worker.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct BubblewrapConfig {
|
||||||
|
/// Pinned Bubblewrap executable.
|
||||||
|
pub bubblewrap: PathBuf,
|
||||||
|
/// Official Ghidra headless launcher or purpose-built probe executable.
|
||||||
|
pub worker_program: PathBuf,
|
||||||
|
/// Worker arguments, already validated and never interpreted by a shell.
|
||||||
|
pub worker_arguments: Vec<OsString>,
|
||||||
|
/// Exact pinned Nix closure roots visible read-only at their host paths.
|
||||||
|
pub readonly_paths: Vec<PathBuf>,
|
||||||
|
/// Staged Sample exposed read-only, when applicable.
|
||||||
|
pub staged_sample: Option<PathBuf>,
|
||||||
|
/// Invocation and project/staging paths exposed read-write.
|
||||||
|
pub writable_paths: Vec<PathBuf>,
|
||||||
|
/// Empty private home created by trusted Rust.
|
||||||
|
pub private_home: PathBuf,
|
||||||
|
/// Empty private temporary directory created by trusted Rust.
|
||||||
|
pub private_tmp: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerCommand {
|
||||||
|
/// Constructs the selected policy without auto-detection or fallback.
|
||||||
|
pub fn for_policy(
|
||||||
|
mode: SandboxMode,
|
||||||
|
direct_program: PathBuf,
|
||||||
|
direct_arguments: Vec<OsString>,
|
||||||
|
bubblewrap: Option<BubblewrapConfig>,
|
||||||
|
) -> Result<Self, SandboxError> {
|
||||||
|
validate_absolute_utf8(&direct_program)?;
|
||||||
|
match mode {
|
||||||
|
SandboxMode::Bubblewrap => {
|
||||||
|
let config = bubblewrap.ok_or(SandboxError::MissingBubblewrapConfiguration)?;
|
||||||
|
build_bubblewrap(config)
|
||||||
|
}
|
||||||
|
SandboxMode::External => Ok(Self {
|
||||||
|
program: direct_program,
|
||||||
|
arguments: direct_arguments,
|
||||||
|
provenance: SandboxProvenance {
|
||||||
|
backend: SandboxBackend::External,
|
||||||
|
verification: SandboxVerification::Unverified,
|
||||||
|
},
|
||||||
|
warnings: vec![policy_warning(
|
||||||
|
"external_sandbox_unverified",
|
||||||
|
"external sandbox isolation cannot be verified by ghidr",
|
||||||
|
)],
|
||||||
|
}),
|
||||||
|
SandboxMode::Off => Ok(Self {
|
||||||
|
program: direct_program,
|
||||||
|
arguments: direct_arguments,
|
||||||
|
provenance: SandboxProvenance {
|
||||||
|
backend: SandboxBackend::Off,
|
||||||
|
verification: SandboxVerification::Disabled,
|
||||||
|
},
|
||||||
|
warnings: vec![policy_warning(
|
||||||
|
"sandbox_disabled",
|
||||||
|
"worker sandbox isolation is explicitly disabled",
|
||||||
|
)],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_bubblewrap(config: BubblewrapConfig) -> Result<WorkerCommand, SandboxError> {
|
||||||
|
validate_absolute_utf8(&config.bubblewrap)?;
|
||||||
|
validate_absolute_utf8(&config.worker_program)?;
|
||||||
|
validate_absolute_utf8(&config.private_home)?;
|
||||||
|
validate_absolute_utf8(&config.private_tmp)?;
|
||||||
|
for path in config
|
||||||
|
.readonly_paths
|
||||||
|
.iter()
|
||||||
|
.chain(config.staged_sample.iter())
|
||||||
|
.chain(config.writable_paths.iter())
|
||||||
|
{
|
||||||
|
validate_absolute_utf8(path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut arguments = os_args([
|
||||||
|
"--die-with-parent",
|
||||||
|
"--new-session",
|
||||||
|
"--unshare-all",
|
||||||
|
"--cap-drop",
|
||||||
|
"ALL",
|
||||||
|
"--clearenv",
|
||||||
|
"--proc",
|
||||||
|
"/proc",
|
||||||
|
"--dev",
|
||||||
|
"/dev",
|
||||||
|
]);
|
||||||
|
// Apply broad writable staging capabilities before narrower read-only
|
||||||
|
// mounts so a staged Sample can never be made writable by a later parent
|
||||||
|
// bind.
|
||||||
|
for path in &config.writable_paths {
|
||||||
|
bind(&mut arguments, "--bind", path, path);
|
||||||
|
}
|
||||||
|
bind(
|
||||||
|
&mut arguments,
|
||||||
|
"--bind",
|
||||||
|
&config.private_home,
|
||||||
|
&config.private_home,
|
||||||
|
);
|
||||||
|
bind(
|
||||||
|
&mut arguments,
|
||||||
|
"--bind",
|
||||||
|
&config.private_tmp,
|
||||||
|
&config.private_tmp,
|
||||||
|
);
|
||||||
|
for path in &config.readonly_paths {
|
||||||
|
bind(&mut arguments, "--ro-bind", path, path);
|
||||||
|
}
|
||||||
|
if let Some(sample) = &config.staged_sample {
|
||||||
|
bind(&mut arguments, "--ro-bind", sample, sample);
|
||||||
|
}
|
||||||
|
arguments.extend(os_args(["--setenv", "HOME"]));
|
||||||
|
arguments.push(config.private_home.as_os_str().to_owned());
|
||||||
|
arguments.extend(os_args(["--setenv", "TMPDIR"]));
|
||||||
|
arguments.push(config.private_tmp.as_os_str().to_owned());
|
||||||
|
arguments.push(OsString::from("--chdir"));
|
||||||
|
arguments.push(config.private_tmp.as_os_str().to_owned());
|
||||||
|
arguments.push(OsString::from("--"));
|
||||||
|
arguments.push(config.worker_program.as_os_str().to_owned());
|
||||||
|
arguments.extend(config.worker_arguments);
|
||||||
|
|
||||||
|
Ok(WorkerCommand {
|
||||||
|
program: config.bubblewrap,
|
||||||
|
arguments,
|
||||||
|
provenance: SandboxProvenance {
|
||||||
|
backend: SandboxBackend::Bubblewrap,
|
||||||
|
verification: SandboxVerification::Verified,
|
||||||
|
},
|
||||||
|
warnings: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind(arguments: &mut Vec<OsString>, flag: &str, source: &Path, destination: &Path) {
|
||||||
|
arguments.push(OsString::from(flag));
|
||||||
|
arguments.push(source.as_os_str().to_owned());
|
||||||
|
arguments.push(destination.as_os_str().to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn os_args<const N: usize>(values: [&str; N]) -> Vec<OsString> {
|
||||||
|
values.into_iter().map(OsString::from).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_absolute_utf8(path: &Path) -> Result<(), SandboxError> {
|
||||||
|
if !path.is_absolute()
|
||||||
|
|| path.to_str().is_none()
|
||||||
|
|| path
|
||||||
|
.components()
|
||||||
|
.any(|component| matches!(component, Component::ParentDir))
|
||||||
|
{
|
||||||
|
return Err(SandboxError::InvalidCapabilityPath(path.to_path_buf()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn policy_warning(code: &str, message: &str) -> Warning {
|
||||||
|
Warning {
|
||||||
|
code: code.to_owned(),
|
||||||
|
message: message.to_owned(),
|
||||||
|
details: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sandbox construction failure before worker launch.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum SandboxError {
|
||||||
|
/// Bubblewrap mode requires its pinned executable and capability list.
|
||||||
|
#[error("bubblewrap mode requires an explicit production configuration")]
|
||||||
|
MissingBubblewrapConfiguration,
|
||||||
|
/// Every path crossing the sandbox boundary must be absolute UTF-8.
|
||||||
|
#[error("sandbox capability path must be absolute UTF-8 without parent traversal: {0:?}")]
|
||||||
|
InvalidCapabilityPath(PathBuf),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends the two fixed adapter protocol paths to a worker argument vector.
|
||||||
|
#[must_use]
|
||||||
|
pub fn adapter_file_arguments(request: &Path, response: &Path) -> Vec<OsString> {
|
||||||
|
vec![
|
||||||
|
OsString::from("--request"),
|
||||||
|
request.as_os_str().to_owned(),
|
||||||
|
OsString::from("--response"),
|
||||||
|
response.as_os_str().to_owned(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether an argument vector contains an exact OS string.
|
||||||
|
#[must_use]
|
||||||
|
pub fn contains_argument(arguments: &[OsString], expected: impl AsRef<OsStr>) -> bool {
|
||||||
|
arguments
|
||||||
|
.iter()
|
||||||
|
.any(|argument| argument == expected.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::expect_used)]
|
||||||
|
mod tests {
|
||||||
|
use std::{ffi::OsString, path::PathBuf};
|
||||||
|
|
||||||
|
use super::{BubblewrapConfig, WorkerCommand, contains_argument};
|
||||||
|
use crate::{
|
||||||
|
cli::SandboxMode,
|
||||||
|
operation::{SandboxBackend, SandboxVerification},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bubblewrap_has_minimal_namespaces_capabilities_and_parent_death() {
|
||||||
|
let command = WorkerCommand::for_policy(
|
||||||
|
SandboxMode::Bubblewrap,
|
||||||
|
PathBuf::from("/nix/store/worker/bin/worker"),
|
||||||
|
vec![],
|
||||||
|
Some(BubblewrapConfig {
|
||||||
|
bubblewrap: PathBuf::from("/nix/store/bwrap/bin/bwrap"),
|
||||||
|
worker_program: PathBuf::from("/nix/store/worker/bin/worker"),
|
||||||
|
worker_arguments: vec![OsString::from("--request")],
|
||||||
|
readonly_paths: vec![PathBuf::from("/nix/store/worker")],
|
||||||
|
staged_sample: Some(PathBuf::from("/store/staging/sample")),
|
||||||
|
writable_paths: vec![PathBuf::from("/store/staging")],
|
||||||
|
private_home: PathBuf::from("/store/staging/home"),
|
||||||
|
private_tmp: PathBuf::from("/store/staging/tmp"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("bubblewrap command");
|
||||||
|
for flag in [
|
||||||
|
"--die-with-parent",
|
||||||
|
"--new-session",
|
||||||
|
"--unshare-all",
|
||||||
|
"--cap-drop",
|
||||||
|
"--clearenv",
|
||||||
|
] {
|
||||||
|
assert!(contains_argument(&command.arguments, flag));
|
||||||
|
}
|
||||||
|
assert_eq!(command.provenance.backend, SandboxBackend::Bubblewrap);
|
||||||
|
assert_eq!(
|
||||||
|
command.provenance.verification,
|
||||||
|
SandboxVerification::Verified
|
||||||
|
);
|
||||||
|
let writable_position = command
|
||||||
|
.arguments
|
||||||
|
.iter()
|
||||||
|
.position(|argument| argument == "/store/staging")
|
||||||
|
.expect("writable bind");
|
||||||
|
let sample_position = command
|
||||||
|
.arguments
|
||||||
|
.iter()
|
||||||
|
.position(|argument| argument == "/store/staging/sample")
|
||||||
|
.expect("Sample bind");
|
||||||
|
assert!(sample_position > writable_position);
|
||||||
|
assert!(command.warnings.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_and_off_are_explicit_in_provenance_and_warnings() {
|
||||||
|
for (mode, backend, verification, code) in [
|
||||||
|
(
|
||||||
|
SandboxMode::External,
|
||||||
|
SandboxBackend::External,
|
||||||
|
SandboxVerification::Unverified,
|
||||||
|
"external_sandbox_unverified",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SandboxMode::Off,
|
||||||
|
SandboxBackend::Off,
|
||||||
|
SandboxVerification::Disabled,
|
||||||
|
"sandbox_disabled",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let command = WorkerCommand::for_policy(mode, PathBuf::from("/worker"), vec![], None)
|
||||||
|
.expect("direct command");
|
||||||
|
assert_eq!(command.provenance.backend, backend);
|
||||||
|
assert_eq!(command.provenance.verification, verification);
|
||||||
|
assert_eq!(command.warnings[0].code, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
tests/bubblewrap_probe.rs
Normal file
114
tests/bubblewrap_probe.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
#![allow(clippy::expect_used)]
|
||||||
|
#![doc = "Host-permitted production Bubblewrap network-isolation probe."]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
ffi::OsString,
|
||||||
|
net::TcpListener,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::Command,
|
||||||
|
};
|
||||||
|
|
||||||
|
use ghidra_cli::{
|
||||||
|
cli::SandboxMode,
|
||||||
|
sandbox::{BubblewrapConfig, WorkerCommand},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn production_bubblewrap_command_cannot_reach_host_listener_when_supported() {
|
||||||
|
let Some(bubblewrap) = bubblewrap_path() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let fake_child = PathBuf::from(env!("CARGO_BIN_EXE_ghidr-fake-child"));
|
||||||
|
let Ok(listener) = TcpListener::bind("127.0.0.1:0") else {
|
||||||
|
// Some CI sandboxes prohibit even loopback listeners.
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let address = listener.local_addr().expect("listener address").to_string();
|
||||||
|
|
||||||
|
let direct = Command::new(&fake_child)
|
||||||
|
.args(["network-probe", &address])
|
||||||
|
.status()
|
||||||
|
.expect("direct probe process");
|
||||||
|
assert!(
|
||||||
|
direct.success(),
|
||||||
|
"control probe must reach the host listener"
|
||||||
|
);
|
||||||
|
|
||||||
|
let private = tempfile::tempdir().expect("sandbox private directory");
|
||||||
|
let home = private.path().join("home");
|
||||||
|
let temporary = private.path().join("tmp");
|
||||||
|
std::fs::create_dir(&home).expect("private home");
|
||||||
|
std::fs::create_dir(&temporary).expect("private tmp");
|
||||||
|
|
||||||
|
let benign = bubblewrap_command(
|
||||||
|
&bubblewrap,
|
||||||
|
&fake_child,
|
||||||
|
vec![OsString::from("exit-success")],
|
||||||
|
&home,
|
||||||
|
&temporary,
|
||||||
|
);
|
||||||
|
let benign_status = Command::new(&benign.program)
|
||||||
|
.args(&benign.arguments)
|
||||||
|
.status()
|
||||||
|
.expect("Bubblewrap capability probe");
|
||||||
|
if !benign_status.success() {
|
||||||
|
// User namespaces are legitimately unavailable on some test hosts.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isolated = bubblewrap_command(
|
||||||
|
&bubblewrap,
|
||||||
|
&fake_child,
|
||||||
|
vec![OsString::from("network-probe"), OsString::from(address)],
|
||||||
|
&home,
|
||||||
|
&temporary,
|
||||||
|
);
|
||||||
|
let isolated_status = Command::new(&isolated.program)
|
||||||
|
.args(&isolated.arguments)
|
||||||
|
.status()
|
||||||
|
.expect("isolated probe");
|
||||||
|
assert!(
|
||||||
|
!isolated_status.success(),
|
||||||
|
"worker unexpectedly reached a host listener across --unshare-all"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bubblewrap_command(
|
||||||
|
bubblewrap: &Path,
|
||||||
|
fake_child: &Path,
|
||||||
|
worker_arguments: Vec<OsString>,
|
||||||
|
home: &Path,
|
||||||
|
temporary: &Path,
|
||||||
|
) -> WorkerCommand {
|
||||||
|
WorkerCommand::for_policy(
|
||||||
|
SandboxMode::Bubblewrap,
|
||||||
|
fake_child.to_path_buf(),
|
||||||
|
worker_arguments.clone(),
|
||||||
|
Some(BubblewrapConfig {
|
||||||
|
bubblewrap: bubblewrap.to_path_buf(),
|
||||||
|
worker_program: fake_child.to_path_buf(),
|
||||||
|
worker_arguments,
|
||||||
|
readonly_paths: vec![PathBuf::from("/nix/store"), fake_child.to_path_buf()],
|
||||||
|
staged_sample: None,
|
||||||
|
writable_paths: Vec::new(),
|
||||||
|
private_home: home.to_path_buf(),
|
||||||
|
private_tmp: temporary.to_path_buf(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("production Bubblewrap command")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bubblewrap_path() -> Option<PathBuf> {
|
||||||
|
env::var_os("GHIDR_TEST_BWRAP")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| find_in_path("bwrap"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_in_path(executable: &str) -> Option<PathBuf> {
|
||||||
|
env::var_os("PATH").and_then(|path| {
|
||||||
|
env::split_paths(&path)
|
||||||
|
.map(|directory| directory.join(executable))
|
||||||
|
.find(|candidate| candidate.is_file())
|
||||||
|
})
|
||||||
|
}
|
||||||
118
tests/support/fake_child.rs
Normal file
118
tests/support/fake_child.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![allow(missing_docs)]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
fs::{self, OpenOptions},
|
||||||
|
io::{self, Read as _, Write as _},
|
||||||
|
net::TcpStream,
|
||||||
|
os::unix::fs::OpenOptionsExt as _,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::ExitCode,
|
||||||
|
thread,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
match execute() {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(error) => {
|
||||||
|
let _ignored = writeln!(io::stderr().lock(), "fake child: {error}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut arguments = env::args_os().skip(1);
|
||||||
|
let behavior = arguments.next().ok_or("missing behavior")?;
|
||||||
|
if behavior == "exit-success" {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if behavior == "network-probe" {
|
||||||
|
let address = arguments.next().ok_or("missing probe address")?;
|
||||||
|
TcpStream::connect(address.to_str().ok_or("probe address is not UTF-8")?)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let request = PathBuf::from(arguments.next().ok_or("missing request path")?);
|
||||||
|
let response = PathBuf::from(arguments.next().ok_or("missing response path")?);
|
||||||
|
match behavior.to_str().ok_or("behavior is not UTF-8")? {
|
||||||
|
"success" => write_response(&request, &response, false, true),
|
||||||
|
"mismatch" => write_response(&request, &response, true, true),
|
||||||
|
"temporary-only" => write_response(&request, &response, false, false),
|
||||||
|
"no-response" => Ok(()),
|
||||||
|
"sleep" => {
|
||||||
|
let milliseconds = arguments
|
||||||
|
.next()
|
||||||
|
.ok_or("missing sleep milliseconds")?
|
||||||
|
.to_str()
|
||||||
|
.ok_or("sleep value is not UTF-8")?
|
||||||
|
.parse::<u64>()?;
|
||||||
|
thread::sleep(Duration::from_millis(milliseconds));
|
||||||
|
write_response(&request, &response, false, true)
|
||||||
|
}
|
||||||
|
"flood" => {
|
||||||
|
let count = arguments
|
||||||
|
.next()
|
||||||
|
.ok_or("missing flood byte count")?
|
||||||
|
.to_str()
|
||||||
|
.ok_or("flood value is not UTF-8")?
|
||||||
|
.parse::<usize>()?;
|
||||||
|
write_repeated(io::stdout().lock(), b'o', count)?;
|
||||||
|
write_repeated(io::stderr().lock(), b'e', count)?;
|
||||||
|
write_response(&request, &response, false, true)
|
||||||
|
}
|
||||||
|
_ => Err("unknown behavior".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_repeated(mut writer: impl io::Write, byte: u8, mut remaining: usize) -> io::Result<()> {
|
||||||
|
let buffer = vec![byte; 64 * 1024];
|
||||||
|
while remaining > 0 {
|
||||||
|
let count = remaining.min(buffer.len());
|
||||||
|
writer.write_all(&buffer[..count])?;
|
||||||
|
remaining -= count;
|
||||||
|
}
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_response(
|
||||||
|
request_path: &Path,
|
||||||
|
response_path: &Path,
|
||||||
|
mismatch: bool,
|
||||||
|
publish: bool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut request_bytes = Vec::new();
|
||||||
|
fs::File::open(request_path)?.read_to_end(&mut request_bytes)?;
|
||||||
|
let request: serde_json::Value = serde_json::from_slice(&request_bytes)?;
|
||||||
|
let invocation_id = if mismatch {
|
||||||
|
serde_json::Value::String("ffffffffffffffffffffffffffffffff".to_owned())
|
||||||
|
} else {
|
||||||
|
request["invocation_id"].clone()
|
||||||
|
};
|
||||||
|
let response = serde_json::json!({
|
||||||
|
"protocol_version": request["protocol_version"],
|
||||||
|
"invocation_id": invocation_id,
|
||||||
|
"operation": request["operation"],
|
||||||
|
"result": {
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"ready": true,
|
||||||
|
"components": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let bytes = serde_json::to_vec(&response)?;
|
||||||
|
let temporary = response_path.with_file_name("response.json.tmp");
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&temporary)?;
|
||||||
|
file.write_all(&bytes)?;
|
||||||
|
file.sync_all()?;
|
||||||
|
if publish {
|
||||||
|
fs::rename(temporary, response_path)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
187
tests/worker_lifecycle.rs
Normal file
187
tests/worker_lifecycle.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
#![allow(clippy::expect_used)]
|
||||||
|
#![doc = "Controllable fake-child coverage for synchronous worker execution."]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
ffi::OsString,
|
||||||
|
fs,
|
||||||
|
os::unix::fs::PermissionsExt as _,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::atomic::{AtomicU32, Ordering},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use ghidra_cli::{
|
||||||
|
cli::SandboxMode,
|
||||||
|
domain::{InvocationId, Limits, PositiveU64},
|
||||||
|
operation::{ADAPTER_PROTOCOL_VERSION, Operation},
|
||||||
|
process::{
|
||||||
|
Cancellation, Invocation, InvocationFailureKind, NativePhaseTimeouts, NeverCancel, run,
|
||||||
|
},
|
||||||
|
protocol::{AdapterRequest, InvocationFiles, RequestArguments},
|
||||||
|
sandbox::WorkerCommand,
|
||||||
|
};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn positive(value: u64) -> PositiveU64 {
|
||||||
|
PositiveU64::try_from(value).expect("positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request() -> AdapterRequest {
|
||||||
|
AdapterRequest {
|
||||||
|
protocol_version: ADAPTER_PROTOCOL_VERSION,
|
||||||
|
invocation_id: InvocationId::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").expect("identifier"),
|
||||||
|
operation: Operation::Doctor,
|
||||||
|
staged_sample: None,
|
||||||
|
analysis_path: None,
|
||||||
|
limits: Limits {
|
||||||
|
max_heap_mib: positive(2048),
|
||||||
|
max_cpu: positive(2),
|
||||||
|
analysis_timeout_seconds: positive(600),
|
||||||
|
decompile_timeout_seconds: None,
|
||||||
|
child_watchdog_seconds: positive(120),
|
||||||
|
max_sample_bytes: positive(1_073_741_824),
|
||||||
|
max_inline_bytes: positive(65_536),
|
||||||
|
},
|
||||||
|
arguments: RequestArguments::Doctor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture(behavior: &str, extra: &[&str]) -> (TempDir, InvocationFiles, WorkerCommand) {
|
||||||
|
let root = tempfile::tempdir().expect("temporary directory");
|
||||||
|
let invocation_directory = root.path().join("invocation");
|
||||||
|
fs::create_dir(&invocation_directory).expect("invocation directory");
|
||||||
|
let files = InvocationFiles::new(invocation_directory).expect("protocol paths");
|
||||||
|
let mut arguments = vec![
|
||||||
|
OsString::from(behavior),
|
||||||
|
files.request_path().into_os_string(),
|
||||||
|
files.response_path().into_os_string(),
|
||||||
|
];
|
||||||
|
arguments.extend(extra.iter().map(OsString::from));
|
||||||
|
let command = WorkerCommand::for_policy(
|
||||||
|
SandboxMode::Off,
|
||||||
|
PathBuf::from(env!("CARGO_BIN_EXE_ghidr-fake-child")),
|
||||||
|
arguments,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("worker command");
|
||||||
|
(root, files, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invocation<'a>(
|
||||||
|
request: &'a AdapterRequest,
|
||||||
|
files: &'a InvocationFiles,
|
||||||
|
command: &'a WorkerCommand,
|
||||||
|
) -> Invocation<'a> {
|
||||||
|
Invocation::new(
|
||||||
|
request,
|
||||||
|
files,
|
||||||
|
command,
|
||||||
|
NativePhaseTimeouts {
|
||||||
|
analysis_seconds: None,
|
||||||
|
decompile_seconds: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("derived Invocation")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_attempt_writes_private_request_and_accepts_only_strict_final_response() {
|
||||||
|
let (_root, files, command) = fixture("success", &[]);
|
||||||
|
let request = request();
|
||||||
|
let completed =
|
||||||
|
run(invocation(&request, &files, &command), &NeverCancel).expect("completed invocation");
|
||||||
|
assert_eq!(completed.response.operation, Operation::Doctor);
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(files.request_path())
|
||||||
|
.expect("request metadata")
|
||||||
|
.permissions()
|
||||||
|
.mode()
|
||||||
|
& 0o777,
|
||||||
|
0o600,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn temporary_response_is_never_accepted() {
|
||||||
|
let (_root, files, command) = fixture("temporary-only", &[]);
|
||||||
|
let request = request();
|
||||||
|
let failure = run(invocation(&request, &files, &command), &NeverCancel)
|
||||||
|
.expect_err("temporary response must fail");
|
||||||
|
assert_eq!(failure.kind, InvocationFailureKind::ResponseProtocol);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mismatched_echo_is_a_protocol_violation() {
|
||||||
|
let (_root, files, command) = fixture("mismatch", &[]);
|
||||||
|
let request = request();
|
||||||
|
let failure =
|
||||||
|
run(invocation(&request, &files, &command), &NeverCancel).expect_err("mismatch must fail");
|
||||||
|
assert_eq!(failure.kind, InvocationFailureKind::ResponseProtocol);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_full_pipes_are_drained_without_deadlock_and_capture_is_bounded() {
|
||||||
|
let count = (9 * 1024 * 1024).to_string();
|
||||||
|
let (_root, files, command) = fixture("flood", &[&count]);
|
||||||
|
let request = request();
|
||||||
|
let completed =
|
||||||
|
run(invocation(&request, &files, &command), &NeverCancel).expect("flood invocation");
|
||||||
|
assert!(completed.diagnostics.truncated());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watchdog_terminates_and_reaps_the_process_group() {
|
||||||
|
let (_root, files, command) = fixture("sleep", &["5000"]);
|
||||||
|
let request = request();
|
||||||
|
let failure = run(
|
||||||
|
invocation(&request, &files, &command).with_test_watchdog(Duration::from_millis(30)),
|
||||||
|
&NeverCancel,
|
||||||
|
)
|
||||||
|
.expect_err("watchdog must fire");
|
||||||
|
assert_eq!(failure.kind, InvocationFailureKind::WatchdogTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Interrupts(AtomicU32);
|
||||||
|
|
||||||
|
impl Cancellation for Interrupts {
|
||||||
|
fn interrupt_count(&self) -> u32 {
|
||||||
|
self.0.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_interrupt_escalates_without_waiting_for_grace() {
|
||||||
|
let (_root, files, command) = fixture("sleep", &["5000"]);
|
||||||
|
let request = request();
|
||||||
|
let interrupts = Interrupts(AtomicU32::new(2));
|
||||||
|
let before = std::time::Instant::now();
|
||||||
|
let failure =
|
||||||
|
run(invocation(&request, &files, &command), &interrupts).expect_err("interrupt must win");
|
||||||
|
assert_eq!(failure.kind, InvocationFailureKind::Interrupted);
|
||||||
|
assert!(before.elapsed() < Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_paths_are_absolute_in_fixture() {
|
||||||
|
let (_root, files, _command) = fixture("success", &[]);
|
||||||
|
assert!(Path::new(&files.request_path()).is_absolute());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invocation_rejects_a_competing_total_watchdog_value() {
|
||||||
|
let (_root, files, command) = fixture("success", &[]);
|
||||||
|
let mut request = request();
|
||||||
|
request.limits.child_watchdog_seconds = positive(121);
|
||||||
|
assert!(
|
||||||
|
Invocation::new(
|
||||||
|
&request,
|
||||||
|
&files,
|
||||||
|
&command,
|
||||||
|
NativePhaseTimeouts {
|
||||||
|
analysis_seconds: None,
|
||||||
|
decompile_seconds: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue