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"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -49,7 +49,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -73,6 +73,21 @@ version = "0.22.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "bstr"
|
||||
version = "1.13.0"
|
||||
|
|
@ -84,6 +99,12 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.4"
|
||||
|
|
@ -130,18 +151,85 @@ version = "1.0.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "difflib"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "ghidra-cli"
|
||||
version = "0.1.0"
|
||||
|
|
@ -149,9 +237,14 @@ dependencies = [
|
|||
"assert_cmd",
|
||||
"base64",
|
||||
"clap",
|
||||
"getrandom",
|
||||
"hex",
|
||||
"rustix",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
|
|
@ -161,6 +254,12 @@ version = "0.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
|
|
@ -179,12 +278,24 @@ version = "0.2.189"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
|
|
@ -236,6 +347,12 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.26"
|
||||
|
|
@ -262,6 +379,19 @@ version = "0.4.16"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "schemars"
|
||||
version = "1.0.4"
|
||||
|
|
@ -341,6 +471,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
|
|
@ -369,6 +510,19 @@ dependencies = [
|
|||
"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]]
|
||||
name = "termtree"
|
||||
version = "0.5.1"
|
||||
|
|
@ -395,6 +549,12 @@ dependencies = [
|
|||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
|
@ -407,6 +567,12 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
|
|
@ -416,21 +582,116 @@ dependencies = [
|
|||
"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]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
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]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
|
|
|
|||
|
|
@ -15,16 +15,25 @@ path = "src/lib.rs"
|
|||
name = "ghidr"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "ghidr-fake-child"
|
||||
path = "tests/support/fake_child.rs"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22.1"
|
||||
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"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
sha2 = "0.10.9"
|
||||
thiserror = "2.0.18"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2.1.2"
|
||||
tempfile = "=3.21.0"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
|
|
|||
|
|
@ -40,6 +40,13 @@ impl fmt::Display for Digest {
|
|||
pub struct InvocationId(String);
|
||||
|
||||
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.
|
||||
pub fn parse(value: &str) -> Result<Self, IdentifierError> {
|
||||
if value.len() != 32
|
||||
|
|
@ -51,6 +58,18 @@ impl InvocationId {
|
|||
}
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ pub mod domain;
|
|||
pub mod error;
|
||||
pub mod operation;
|
||||
pub mod output;
|
||||
pub mod process;
|
||||
pub mod protocol;
|
||||
pub mod sandbox;
|
||||
pub mod schema;
|
||||
|
||||
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::{
|
||||
ffi::OsStr,
|
||||
io::{self, Read},
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{self, Read, Write},
|
||||
os::unix::fs::OpenOptionsExt as _,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
|
|
@ -12,7 +14,10 @@ use thiserror::Error;
|
|||
|
||||
use crate::{
|
||||
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).
|
||||
|
|
@ -20,6 +25,13 @@ pub const MAX_REQUEST_BYTES: usize = 1_048_576;
|
|||
/// Maximum exact serialized `response.json` size (256 MiB).
|
||||
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.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(transparent)]
|
||||
|
|
@ -164,6 +176,129 @@ pub fn validate_response_echo(
|
|||
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>(
|
||||
value: &T,
|
||||
limit: usize,
|
||||
|
|
@ -238,6 +373,21 @@ pub enum ProtocolCodecError {
|
|||
/// Operation echo differs.
|
||||
#[error("adapter operation mismatch")]
|
||||
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.
|
||||
#[error("platform cannot represent protocol size bound")]
|
||||
PlatformLimit,
|
||||
|
|
@ -260,10 +410,45 @@ pub fn requests_human_format(arguments: &[impl AsRef<OsStr>]) -> bool {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
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]
|
||||
fn response_reader_stops_at_bound_plus_one() {
|
||||
|
|
@ -302,4 +487,41 @@ mod tests {
|
|||
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