feat: implement worker lifecycle and sandbox

This commit is contained in:
hermes 2026-07-28 19:51:32 +00:00
commit b9b9bfe05a
13 changed files with 2456 additions and 7 deletions

267
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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.

View file

@ -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
View 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
View 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
View 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
View 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,
};

View file

@ -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
View 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
View 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
View 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
View 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()
);
}