diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5efb1f0b..a7043ca5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 # GPUI's X11 and Wayland backends link these; the tray icon and the keyring speak D-Bus in pure Rust. - name: Install Linux build dependencies @@ -87,6 +89,20 @@ jobs: - name: Cache Cargo uses: Swatinem/rust-cache@v2 + - name: Observe original and candidate Windows console seams + if: runner.os == 'Windows' + env: + CONSOLE_CANDIDATE_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + run: python scripts/qualify-provider-console.py + + - name: Upload Windows console qualification + if: always() && runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: provider-console + path: artifacts/provider-console/ + retention-days: 14 + - name: Format Check run: cargo fmt --all --check diff --git a/crates/codexbar-app/src/widgets/provider.rs b/crates/codexbar-app/src/widgets/provider.rs index 20c551fa..f081ecfb 100644 --- a/crates/codexbar-app/src/widgets/provider.rs +++ b/crates/codexbar-app/src/widgets/provider.rs @@ -190,7 +190,7 @@ fn open_codexbar(target: Option<&host::Target>) { command.arg(crate::handoff::METRIC_ARG).arg(metric); } } - let _ = command.spawn(); + let _ = codexbar_providers::platform::spawn(&mut command); } /// One provider object per client request; the server lives as long as any is held (out-of-process COM's rule). diff --git a/crates/codexbar-providers/Cargo.toml b/crates/codexbar-providers/Cargo.toml index 8eb9ed3e..908c2f98 100644 --- a/crates/codexbar-providers/Cargo.toml +++ b/crates/codexbar-providers/Cargo.toml @@ -21,7 +21,7 @@ unicode-normalization = "0.1.25" libc = "0.2" [target.'cfg(windows)'.dependencies] -windows.workspace = true +windows = { workspace = true, features = ["Win32_System_Console", "Win32_System_StationsAndDesktops", "Win32_System_SystemInformation"] } [lints] workspace = true diff --git a/crates/codexbar-providers/src/command.rs b/crates/codexbar-providers/src/command.rs index 9850ff18..96d26944 100644 --- a/crates/codexbar-providers/src/command.rs +++ b/crates/codexbar-providers/src/command.rs @@ -1,3 +1,4 @@ +#[cfg(not(windows))] use std::process::Stdio; use std::time::{Duration, Instant}; @@ -23,29 +24,28 @@ pub trait CommandRunner: Send + Sync { fn run(&self, program: &str, args: &[&str], timeout: Duration) -> Result; } -/// Runs commands with no console window, bounded output and a deadline covering collection and process exit. +/// Runs background commands with no visible console, bounded output and a deadline covering collection and exit. /// Each stream is limited to 8 MiB. Oversized output fails without returning or logging a partial payload. #[derive(Default)] pub struct SystemCommandRunner; +#[cfg(windows)] +mod background; mod pipe; +#[cfg(any(windows, test))] +mod windows_plan; +#[cfg(windows)] +use background::{Child, Contained, spawn}; +#[cfg(not(windows))] +type Child = std::process::Child; +#[cfg(not(windows))] +type Contained = crate::contained::Contained; const OUTPUT_LIMIT: usize = 8 * 1024 * 1024; const CHECK_INTERVAL: Duration = Duration::from_millis(25); impl CommandRunner for SystemCommandRunner { fn run(&self, program: &str, args: &[&str], timeout: Duration) -> Result { - let mut command = crate::platform::command(program); - command - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt as _; - command.process_group(0); - } - let inner = crate::contained::spawn(&mut command).map_err(spawn_error)?; + let inner = spawn(program, args).map_err(spawn_error)?; let mut process = Running { inner, stopped: false }; let output = collect(&mut process.inner.child, timeout); let status = process.stop().map_err(|_| CommandError::Failed)?; @@ -58,6 +58,19 @@ impl CommandRunner for SystemCommandRunner { } } +#[cfg(not(windows))] +fn spawn(program: &str, args: &[&str]) -> std::io::Result { + use std::os::unix::process::CommandExt as _; + let mut command = crate::platform::command(program); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command.process_group(0); + crate::contained::spawn(&mut command) +} + fn spawn_error(error: std::io::Error) -> CommandError { if error.kind() == std::io::ErrorKind::NotFound { CommandError::NotFound @@ -68,7 +81,7 @@ fn spawn_error(error: std::io::Error) -> CommandError { /// The root remains unreaped until group cleanup, so its process-group ID cannot refer to somebody else's work. struct Running { - inner: crate::contained::Contained, + inner: Contained, stopped: bool, } impl Running { @@ -129,7 +142,7 @@ impl Capture

{ } } -fn collect(child: &mut std::process::Child, timeout: Duration) -> Result<(Vec, Vec), CommandError> { +fn collect(child: &mut Child, timeout: Duration) -> Result<(Vec, Vec), CommandError> { let start = Instant::now(); let mut stdout = Capture::new(child.stdout.take().ok_or(CommandError::Failed)?)?; let mut stderr = Capture::new(child.stderr.take().ok_or(CommandError::Failed)?)?; diff --git a/crates/codexbar-providers/src/command/background.rs b/crates/codexbar-providers/src/command/background.rs new file mode 100644 index 00000000..8c6c524b --- /dev/null +++ b/crates/codexbar-providers/src/command/background.rs @@ -0,0 +1,154 @@ +//! Windows usage queries get a console hidden at creation, which ordinary descendants inherit. +//! This is not a sandbox against a process explicitly requesting another console or showing UI. +use std::fs::File; +use std::io; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle}; +use std::os::windows::process::ExitStatusExt as _; +use std::process::ExitStatus; + +use windows::Win32::Foundation::{HANDLE, WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows::Win32::System::Threading::{ + CREATE_NEW_CONSOLE, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, CreateProcessW, EXTENDED_STARTUPINFO_PRESENT, + GetExitCodeProcess, PROCESS_INFORMATION, ResumeThread, TerminateProcess, WaitForSingleObject, +}; +use windows::core::{PCWSTR, PWSTR}; + +mod arguments; +mod stdio; + +#[cfg(test)] +mod tests; + +pub(super) struct Child { + process: OwnedHandle, + pub stdout: Option, + pub stderr: Option, +} + +impl AsRawHandle for Child { + fn as_raw_handle(&self) -> RawHandle { + self.process.as_raw_handle() + } +} + +impl Child { + pub(super) fn try_wait(&mut self) -> io::Result> { + // SAFETY: a retained owned process handle; no blocking wait or PID lookup. + match unsafe { WaitForSingleObject(HANDLE(self.as_raw_handle()), 0) } { + WAIT_OBJECT_0 => self.wait().map(Some), + WAIT_TIMEOUT => Ok(None), + _ => Err(io::Error::last_os_error()), + } + } + + pub(super) fn wait(&mut self) -> io::Result { + self.wait_for(1000) + } + + fn wait_for(&mut self, milliseconds: u32) -> io::Result { + let mut code = 0; + // SAFETY: this process was created by this launcher and its handle stays owned during both queries. + unsafe { + if WaitForSingleObject(HANDLE(self.as_raw_handle()), milliseconds) != WAIT_OBJECT_0 { + return Err(io::Error::other("background process exit could not be confirmed")); + } + GetExitCodeProcess(HANDLE(self.as_raw_handle()), &mut code).map_err(io::Error::other)?; + } + Ok(ExitStatus::from_raw(code)) + } + + fn kill(&self) { + // SAFETY: terminate only our retained process. A failed kill of an exited process is harmless. + let _ = unsafe { TerminateProcess(HANDLE(self.as_raw_handle()), 1) }; + } +} + +pub(super) struct Contained { + pub child: Child, + job: crate::contained::job::Job, +} + +impl Contained { + pub(super) fn stop(&mut self) -> io::Result { + let cleanup = self.job.terminate(); + self.child.kill(); + // The job drain already spent the one-second cleanup budget and verified retained members. + // Never extend it with an unbounded root wait after a failed drain or kill. + let status = self.child.wait_for(0); + cleanup?; + status + } +} + +pub(super) fn spawn(program: &str, args: &[&str]) -> io::Result { + let mut plan = arguments::command_line(program, args)?; + let mut streams = stdio::Prepared::new()?; + let suspended = Suspended::create(&mut plan, &mut streams)?; + let (stdout, stderr) = streams.capture(); + suspended.start(stdout, stderr) +} + +/// Own the exact process and primary thread from suspended creation until job assignment and resume commit. +/// Dropping an uncommitted launch always terminates/reaps that same retained process. +struct Suspended { + child: Option, + thread: OwnedHandle, +} +impl Suspended { + fn create(plan: &mut super::windows_plan::LaunchPlan, streams: &mut stdio::Prepared) -> io::Result { + let startup = streams.startup(); + let mut info = PROCESS_INFORMATION::default(); + // SAFETY: owned NUL-terminated application and writable command line, initialized live startup attributes, + // and writable output. Preserve the caller's token, environment, working directory and desktop. + unsafe { + CreateProcessW( + PCWSTR(plan.application.as_ptr()), + Some(PWSTR(plan.line.as_mut_ptr())), + None, + None, + true, + CREATE_NEW_CONSOLE | CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT, + None, + PCWSTR::null(), + &startup.StartupInfo, + &mut info, + ) + } + .map_err(|error| io::Error::from_raw_os_error(error.code().0 & 0xffff))?; + // SAFETY: successful CreateProcessW transfers unique noninheritable process and primary-thread handles. + let process = unsafe { OwnedHandle::from_raw_handle(info.hProcess.0) }; + let thread = unsafe { OwnedHandle::from_raw_handle(info.hThread.0) }; + Ok(Self { + child: Some(Child { + process, + stdout: None, + stderr: None, + }), + thread, + }) + } + + fn start(mut self, stdout: File, stderr: File) -> io::Result { + let child = self.child.as_mut().unwrap(); + child.stdout = Some(stdout); + child.stderr = Some(stderr); + let job = crate::contained::job::Job::kill_on_close(child) + .ok_or_else(|| io::Error::other("the background process couldn't enter its job"))?; + // SAFETY: retain this suspended primary thread; no provider code ran before job membership. + if unsafe { ResumeThread(HANDLE(self.thread.as_raw_handle())) } == u32::MAX { + return Err(io::Error::other("the background process couldn't be resumed")); + } + Ok(Contained { + child: self.child.take().unwrap(), + job, + }) + } +} +impl Drop for Suspended { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + child.kill(); + let _ = child.wait(); // bounded confirmation, including job-assignment/resume failures. + } + } +} diff --git a/crates/codexbar-providers/src/command/background/arguments.rs b/crates/codexbar-providers/src/command/background/arguments.rs new file mode 100644 index 00000000..7f77ae64 --- /dev/null +++ b/crates/codexbar-providers/src/command/background/arguments.rs @@ -0,0 +1,52 @@ +//! Native environment discovery for the portable Windows launch planner. +use super::super::windows_plan::{self, LaunchPlan}; +use std::ffi::OsString; +use std::io; +use std::os::windows::ffi::OsStringExt as _; +use std::path::{Path, PathBuf}; +use windows::Win32::System::SystemInformation::{GetSystemDirectoryW, GetWindowsDirectoryW}; + +pub(super) fn command_line(program: &str, args: &[&str]) -> io::Result { + let resolved = windows_plan::resolve(program, &executable_dirs(), Path::exists)?; + LaunchPlan::new(resolved, args, system_interpreter) +} + +fn system_interpreter() -> io::Result { + let mut system = [0u16; 32768]; + // SAFETY: writable UTF-16 buffer. Use the system interpreter, never a PATH or COMSPEC replacement. + let size = unsafe { GetSystemDirectoryW(Some(&mut system)) } as usize; + if size == 0 || size >= system.len() { + return Err(io::Error::last_os_error()); + } + Ok(String::from_utf16(&system[..size]).map_err(io::Error::other)? + "\\cmd.exe") +} + +fn executable_dirs() -> Vec { + let mut dirs: Vec<_> = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_owned)) + .into_iter() + .collect(); + dirs.extend(system_dirs()); + if let Some(path) = std::env::var_os("PATH") { + dirs.extend(std::env::split_paths(&path).filter(|path| !path.as_os_str().is_empty())); + } + dirs +} + +fn system_dirs() -> Vec { + let queries: [fn(&mut [u16]) -> u32; 2] = [ + |buffer| unsafe { GetSystemDirectoryW(Some(buffer)) }, + |buffer| unsafe { GetWindowsDirectoryW(Some(buffer)) }, + ]; + let mut dirs = Vec::new(); + for query in queries { + let mut buffer = [0u16; 32768]; + // Each query writes only into this supplied buffer and returns the UTF-16 length. + let size = query(&mut buffer) as usize; + if size > 0 && size < buffer.len() { + dirs.push(PathBuf::from(OsString::from_wide(&buffer[..size]))); + } + } + dirs +} diff --git a/crates/codexbar-providers/src/command/background/stdio.rs b/crates/codexbar-providers/src/command/background/stdio.rs new file mode 100644 index 00000000..7c3b0ce5 --- /dev/null +++ b/crates/codexbar-providers/src/command/background/stdio.rs @@ -0,0 +1,164 @@ +//! Own the standard streams, whitelist memory and launch guard through the complete inheritance interval. +use std::fs::{File, OpenOptions}; +use std::io; +use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _}; +use std::sync::MutexGuard; +use windows::Win32::Foundation::{HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation}; +use windows::Win32::Security::SECURITY_ATTRIBUTES; +use windows::Win32::System::Pipes::CreatePipe; +use windows::Win32::System::Threading::{ + DeleteProcThreadAttributeList, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, STARTF_USESHOWWINDOW, STARTF_USESTDHANDLES, STARTUPINFOEXW, + UpdateProcThreadAttribute, +}; + +// Field order matters on error: delete attributes while their handles remain valid, then close streams, +// then release the launch guard. Handle-list memory is owned by Attributes throughout its native lifetime. +pub(super) struct Prepared { + attributes: Attributes, + streams: Streams, + guard: MutexGuard<'static, ()>, +} + +impl Prepared { + pub(super) fn new() -> io::Result { + let guard = crate::platform::spawn_guard(); + let streams = Streams::new()?; + let attributes = streams.inherit()?; + Ok(Self { + streams, + attributes, + guard, + }) + } + + pub(super) fn startup(&mut self) -> STARTUPINFOEXW { + let handles = self.streams.handles(); + let mut startup = STARTUPINFOEXW::default(); + startup.StartupInfo.cb = std::mem::size_of::() as u32; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW; + startup.StartupInfo.wShowWindow = 0; // SW_HIDE at creation; no later ShowWindow call. + startup.StartupInfo.hStdInput = handles[0]; + startup.StartupInfo.hStdOutput = handles[1]; + startup.StartupInfo.hStdError = handles[2]; + startup.lpAttributeList = self.attributes.pointer(); + startup + } + + pub(super) fn capture(self) -> (File, File) { + let Self { + streams, + attributes, + guard, + } = self; + let Streams { + input, + stdout, + out, + stderr, + err, + } = streams; + drop((attributes, input, out, err)); + drop(guard); + (stdout, stderr) + } +} + +struct Streams { + input: File, + stdout: File, + out: File, + stderr: File, + err: File, +} +impl Streams { + fn new() -> io::Result { + let input = OpenOptions::new().read(true).open("NUL")?; + let (stdout, out) = pipe()?; + let (stderr, err) = pipe()?; + Ok(Self { + input, + stdout, + out, + stderr, + err, + }) + } + + fn handles(&self) -> [HANDLE; 3] { + [raw(&self.input), raw(&self.out), raw(&self.err)] + } + + fn inherit(&self) -> io::Result { + let handles = self.handles(); + for handle in handles { + // SAFETY: only these owned child-side handles become inheritable. Parent read ends never do. + unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT.0, HANDLE_FLAG_INHERIT) } + .map_err(io::Error::other)?; + } + Attributes::handles(handles) + } +} + +fn raw(file: &File) -> HANDLE { + HANDLE(file.as_raw_handle()) +} + +fn pipe() -> io::Result<(File, File)> { + let mut read = HANDLE::default(); + let mut write = HANDLE::default(); + let security = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + ..Default::default() + }; + // SAFETY: writable output handles; default security and noninheritable until explicitly selected above. + unsafe { CreatePipe(&mut read, &mut write, Some(&security), 0) }.map_err(io::Error::other)?; + // SAFETY: successful CreatePipe returned distinct owned file handles, each closed once by File. + Ok(unsafe { (File::from_raw_handle(read.0), File::from_raw_handle(write.0)) }) +} + +/// Own both aligned attribute storage and stable handle-list memory until process creation finishes. +struct Attributes { + storage: Vec, + handles: Box<[HANDLE; 3]>, +} +impl Attributes { + fn handles(handles: [HANDLE; 3]) -> io::Result { + let handles = Box::new(handles); + let mut bytes = 0; + // SAFETY: documented sizing query. Failure with the required byte count is expected. + let _ = unsafe { InitializeProcThreadAttributeList(None, 1, None, &mut bytes) }; + if bytes == 0 { + return Err(io::Error::last_os_error()); + } + let mut storage = vec![0; bytes.div_ceil(std::mem::size_of::())]; + let pointer = LPPROC_THREAD_ATTRIBUTE_LIST(storage.as_mut_ptr().cast()); + // SAFETY: sufficiently sized and aligned writable storage. Only construct the RAII owner on success. + unsafe { InitializeProcThreadAttributeList(Some(pointer), 1, None, &mut bytes) }.map_err(io::Error::other)?; + let mut attributes = Self { storage, handles }; + // SAFETY: initialized list and a live exact-size array of inheritable handles, no pseudo handles. + unsafe { + UpdateProcThreadAttribute( + attributes.pointer(), + 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, + Some(attributes.handles.as_ptr().cast()), + std::mem::size_of_val(attributes.handles.as_ref()), + None, + None, + ) + } + .map_err(io::Error::other)?; + Ok(attributes) + } + + fn pointer(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + LPPROC_THREAD_ATTRIBUTE_LIST(self.storage.as_mut_ptr().cast()) + } +} +impl Drop for Attributes { + fn drop(&mut self) { + // SAFETY: the initialized list is deleted before its backing allocation is freed. + unsafe { DeleteProcThreadAttributeList(self.pointer()) }; + } +} diff --git a/crates/codexbar-providers/src/command/background/tests.rs b/crates/codexbar-providers/src/command/background/tests.rs new file mode 100644 index 00000000..7f8dc9bf --- /dev/null +++ b/crates/codexbar-providers/src/command/background/tests.rs @@ -0,0 +1,62 @@ +use super::*; +use std::os::windows::io::BorrowedHandle; +use windows::Win32::Foundation::CompareObjectHandles; + +#[test] +fn abandoning_a_suspended_launch_terminates_the_retained_process_before_provider_code_runs() { + let executable = std::env::current_exe().unwrap(); + let mut plan = arguments::command_line(executable.to_str().unwrap(), &["--list"]).unwrap(); + let mut streams = stdio::Prepared::new().unwrap(); + let suspended = Suspended::create(&mut plan, &mut streams).unwrap(); + let retained = suspended.child.as_ref().unwrap().process.try_clone().unwrap(); + assert_eq!( + unsafe { WaitForSingleObject(HANDLE(retained.as_raw_handle()), 0) }, + WAIT_TIMEOUT + ); + drop(streams); + drop(suspended); + assert_eq!( + unsafe { WaitForSingleObject(HANDLE(retained.as_raw_handle()), 0) }, + WAIT_OBJECT_0 + ); +} + +#[test] +fn failed_native_creation_releases_pipes_attributes_handles_and_the_launch_guard() { + // An explicit missing EXE reaches native creation after stdio and the whitelist have been prepared. + let missing = std::env::temp_dir() + .join("codexbar-native-missing-271") + .join("missing.exe"); + assert!(!missing.exists()); + let mut plan = arguments::command_line(missing.to_str().unwrap(), &[]).unwrap(); + let mut streams = stdio::Prepared::new().unwrap(); + let startup = streams.startup(); + let originals = [ + startup.StartupInfo.hStdInput, + startup.StartupInfo.hStdOutput, + startup.StartupInfo.hStdError, + ]; + // Retain object identity so another unit test reusing a released handle value cannot affect this proof. + let retained: Vec<_> = originals + .iter() + .map(|handle| { + // SAFETY: streams owns each live handle for the full duplication call. + unsafe { BorrowedHandle::borrow_raw(handle.0) } + .try_clone_to_owned() + .unwrap() + }) + .collect(); + for (handle, retained) in originals.iter().zip(&retained) { + assert!(unsafe { CompareObjectHandles(*handle, HANDLE(retained.as_raw_handle())) }.as_bool()); + } + assert_eq!( + Suspended::create(&mut plan, &mut streams).err().unwrap().kind(), + io::ErrorKind::NotFound + ); + drop(streams); + for (handle, retained) in originals.iter().zip(&retained) { + // SAFETY: read-only comparison; a closed/reused value must no longer refer to the retained original object. + assert!(!unsafe { CompareObjectHandles(*handle, HANDLE(retained.as_raw_handle())) }.as_bool()); + } + drop(crate::platform::spawn_guard()); +} diff --git a/crates/codexbar-providers/src/command/pipe.rs b/crates/codexbar-providers/src/command/pipe.rs index baf765f4..9ffaffd9 100644 --- a/crates/codexbar-providers/src/command/pipe.rs +++ b/crates/codexbar-providers/src/command/pipe.rs @@ -1,6 +1,6 @@ //! Nonblocking reads from the command's exclusively owned anonymous pipes. No reader threads are created. +use super::Child; use std::io::{self, Read}; -use std::process::Child; #[cfg(all(unix, any(target_os = "linux", test)))] mod linux_group; diff --git a/crates/codexbar-providers/src/command/windows_plan.rs b/crates/codexbar-providers/src/command/windows_plan.rs new file mode 100644 index 00000000..daf44fcd --- /dev/null +++ b/crates/codexbar-providers/src/command/windows_plan.rs @@ -0,0 +1,369 @@ +//! Portable Windows launch policy: executable selection, CRT/batch argument encoding and native buffer bounds. +//! This is production Windows policy, also compiled on other hosts for credential-free behavioral tests. +use std::io; +use std::path::{Path, PathBuf}; + +pub(super) struct LaunchPlan { + pub application: Vec, + pub line: Vec, +} + +impl LaunchPlan { + pub(super) fn new( + resolved: String, + args: &[&str], + interpreter: impl FnOnce() -> io::Result, + ) -> io::Result { + let lower = resolved.to_ascii_lowercase(); + let batch = lower.ends_with(".cmd") || lower.ends_with(".bat"); + let (application, line) = if batch { + (interpreter()?, format!("cmd.exe /e:ON /v:OFF /d /c \"\"{resolved}\"")) + } else { + (resolved.clone(), format!("\"{resolved}\"")) + }; + Ok(Self { + application: wide(&application)?, + line: finish_line(line, args, batch)?, + }) + } +} + +/// Search only the supplied application/system/PATH directories, never an implicit working directory. +/// Explicit paths prefer an existing `.exe` suffix as Rust Command does, then retain the original path. +pub(super) fn resolve(program: &str, directories: &[PathBuf], exists: impl Fn(&Path) -> bool) -> io::Result { + wide(program)?; + let name = program.rsplit(['\\', '/']).next().unwrap(); + if name.is_empty() || matches!(name, "." | "..") || name.ends_with(':') { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "program path has no file name", + )); + } + let path = if program.contains(['\\', '/', ':']) { + explicit_path(program, &exists) + } else { + let name = if program.contains('.') { + program.to_owned() + } else { + format!("{program}.exe") + }; + directories + .iter() + .map(|dir| dir.join(&name)) + .find(|path| exists(path)) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "program not found"))? + }; + path.into_os_string() + .into_string() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "program path is not Unicode")) +} + +fn explicit_path(program: &str, exists: &impl Fn(&Path) -> bool) -> PathBuf { + let path = PathBuf::from(program); + if program.to_ascii_lowercase().ends_with(".exe") { + return path; + } + let mut suffix = path.as_os_str().to_owned(); + suffix.push(".exe"); + let suffix = PathBuf::from(suffix); + if exists(&suffix) { suffix } else { path } +} + +fn finish_line(mut line: String, args: &[&str], batch: bool) -> io::Result> { + for arg in args { + line.push(' '); + append(&mut line, arg, batch)?; + } + if batch { + line.push('"'); + } + let line = wide(&line)?; + if line.len() > 32767 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "command line is too long")); + } + Ok(line) +} + +fn wide(text: &str) -> io::Result> { + if text.contains('\0') { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in command argument")); + } + Ok(text.encode_utf16().chain([0]).collect()) +} + +fn append(line: &mut String, arg: &str, batch: bool) -> io::Result<()> { + if batch && arg.contains(['\r', '\n']) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "newline in batch argument")); + } + append_validated(line, arg, batch); + Ok(()) +} + +fn needs_batch_quotes(ch: char) -> bool { + ch.is_ascii() && !ch.is_ascii_alphanumeric() && !r"#$*+-./:?@\_".contains(ch) +} + +fn needs_quotes(arg: &str, batch: bool) -> bool { + arg.is_empty() + || if batch { + arg.chars().any(needs_batch_quotes) + } else { + arg.contains([' ', '\t', '"']) + } +} + +fn append_validated(line: &mut String, arg: &str, batch: bool) { + let quoted = needs_quotes(arg, batch); + if quoted { + line.push('"'); + } + let mut slashes = 0; + for ch in arg.chars() { + if ch == '\\' { + slashes += 1; + continue; + } + append_character(line, ch, slashes, batch); + slashes = 0; + } + append_end(line, slashes, quoted); +} + +fn append_end(line: &mut String, slashes: usize, quoted: bool) { + line.extend(std::iter::repeat_n('\\', if quoted { 2 * slashes } else { slashes })); + if quoted { + line.push('"'); + } +} + +fn append_character(line: &mut String, ch: char, slashes: usize, batch: bool) { + if ch == '"' { + line.extend(std::iter::repeat_n('\\', 2 * slashes)); + line.push(if batch { '"' } else { '\\' }); + } else { + line.extend(std::iter::repeat_n('\\', slashes)); + } + if batch && ch == '%' { + // cmd expands the empty substring of its built-in CD variable, leaving the literal percent. + line.push_str("%%cd:~,"); + } + line.push(ch); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decoded(buffer: &[u16]) -> String { + assert_eq!(buffer.last(), Some(&0)); + assert!(!buffer[..buffer.len() - 1].contains(&0)); + String::from_utf16(&buffer[..buffer.len() - 1]).unwrap() + } + + fn plan(program: &str, args: &[&str]) -> io::Result { + LaunchPlan::new(program.to_owned(), args, || { + Ok(r"C:\Windows\System32\cmd.exe".to_owned()) + }) + } + + #[test] + fn executable_identity_uses_only_the_ordered_application_system_and_path_candidates() { + let dirs = ["application", "system", "windows", "user-path"].map(PathBuf::from); + let user = dirs[3].join("tool.exe"); + assert_eq!( + resolve("tool", &dirs, |path| path == user).unwrap(), + user.to_str().unwrap() + ); + let application = dirs[0].join("tool.exe"); + assert_eq!( + resolve("tool", &dirs, |path| path == user || path == application).unwrap(), + application.to_str().unwrap() + ); + let system = dirs[1].join("tool.exe"); + assert_eq!( + resolve("tool.exe", &dirs, |path| path == user || path == system).unwrap(), + system.to_str().unwrap() + ); + let shim = dirs[3].join("tool.cmd"); + assert_eq!( + resolve("tool.cmd", &dirs, |path| path == shim).unwrap(), + shim.to_str().unwrap() + ); + assert_eq!( + resolve("tool", &dirs, |_| false).unwrap_err().kind(), + io::ErrorKind::NotFound + ); + // There is no implicit current-directory probe, even if an executable happens to exist there. + assert_eq!( + resolve("tool", &[], |_| panic!("must not search the working directory")) + .unwrap_err() + .kind(), + io::ErrorKind::NotFound + ); + } + + #[test] + fn explicit_windows_paths_do_not_search_or_redirect_an_exe_identity() { + for program in [ + r"C:\tools\tool.EXE", + r"\\server\share\tool.exe", + r".\tool.exe", + "./tool.exe", + ] { + assert_eq!( + resolve(program, &[], |_| panic!( + "explicit exe must go directly to CreateProcessW" + )) + .unwrap(), + program + ); + } + for program in [r"C:\tools\tool", r"C:tool", r".\tool.cmd", "./tool.bat"] { + assert_eq!(resolve(program, &[], |_| false).unwrap(), program); + let suffixed = format!("{program}.exe"); + assert_eq!( + resolve(program, &[], |path| path == Path::new(&suffixed)).unwrap(), + suffixed + ); + } + } + + #[test] + fn invalid_program_paths_cannot_become_search_candidates() { + for program in ["", ".", "..", "C:", r"C:\", "a/", "a\\", "a/..", "a\0b"] { + assert_eq!( + resolve(program, &[], |_| panic!("invalid program must not reach lookup")) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); + } + } + + #[cfg(unix)] + #[test] + fn non_unicode_directory_is_rejected_instead_of_selecting_a_different_program() { + use std::os::unix::ffi::OsStringExt as _; + let directory = PathBuf::from(std::ffi::OsString::from_vec(vec![b'/', 0xff])); + assert_eq!( + resolve("tool", &[directory], |_| true).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + } + + #[test] + fn native_launch_keeps_usage_arguments_and_never_requests_an_interpreter() { + let program = r"C:\Tools\provider.exe"; + let plan = LaunchPlan::new( + program.to_owned(), + &["-p", "/usage", "--output-format", "json", "--print-timeout", "25s"], + || panic!("native launch must not discover or start cmd.exe"), + ) + .unwrap(); + assert_eq!(decoded(&plan.application), program); + assert_eq!( + decoded(&plan.line), + r#""C:\Tools\provider.exe" -p /usage --output-format json --print-timeout 25s"# + ); + assert_eq!(decoded(&self::plan("app.exe", &[]).unwrap().line), "\"app.exe\""); + } + + #[test] + fn crt_arguments_preserve_empty_whitespace_quotes_backslashes_and_shell_characters() { + // Golden encodings are independently exercised by the actual Windows argv fixture too. + for (input, encoded) in [ + ("", r#""""#), + ("two words", r#""two words""#), + ("a\"b", r#""a\"b""#), + (r#"x\"y"#, r#""x\\\"y""#), + ("trailing space \\", "\"trailing space \\\\\""), + (r"\", r"\"), + ("&|<>()^", "&|<>()^"), + ("%PATH%", "%PATH%"), + ("tab\tvalue", "\"tab\tvalue\""), + ("é漢😀", "é漢😀"), + ("a\nb", "a\nb"), + ] { + assert_eq!( + decoded(&plan("app.exe", &[input]).unwrap().line), + format!("\"app.exe\" {encoded}") + ); + } + } + + #[test] + fn batch_shims_select_the_system_interpreter_and_preserve_cmd_literal_rules() { + for program in [r"C:\Tools\provider.CMD", r"C:\Tools\provider.bat"] { + let launch = plan( + program, + &[ + "-p", + "/usage", + "", + "two words", + "a\"b", + "%PATH%", + "!value!", + "&|<>()^", + "é漢😀", + "trailing space \\", + ], + ) + .unwrap(); + assert_eq!(decoded(&launch.application), r"C:\Windows\System32\cmd.exe"); + let expected_args = "-p /usage \"\" \"two words\" \"a\"\"b\" \"%%cd:~,%PATH%%cd:~,%\" \"!value!\" \"&|<>()^\" é漢😀 \"trailing space \\\\\""; + assert_eq!( + decoded(&launch.line), + format!("cmd.exe /e:ON /v:OFF /d /c \"\"{program}\" {expected_args}\"") + ); + } + } + + #[test] + fn invalid_arguments_interpreter_errors_and_native_limits_fail_before_process_creation() { + for program in ["app.exe", "shim.cmd"] { + assert_eq!( + plan(program, &["a\0b"]).err().unwrap().kind(), + io::ErrorKind::InvalidInput + ); + } + assert_eq!( + plan("app\0.exe", &[]).err().unwrap().kind(), + io::ErrorKind::InvalidInput + ); + for value in ["a\nb", "a\rb"] { + assert_eq!( + plan("shim.cmd", &[value]).err().unwrap().kind(), + io::ErrorKind::InvalidInput + ); + } + assert_eq!( + LaunchPlan::new("shim.cmd".into(), &[], || Err(io::ErrorKind::PermissionDenied.into())) + .err() + .unwrap() + .kind(), + io::ErrorKind::PermissionDenied + ); + assert_eq!( + LaunchPlan::new("shim.cmd".into(), &[], || Ok("cmd\0.exe".into())) + .err() + .unwrap() + .kind(), + io::ErrorKind::InvalidInput + ); + let exact = "a".repeat(32756); + assert_eq!(plan("app.exe", &[&exact]).unwrap().line.len(), 32767); + assert_eq!( + plan("app.exe", &[&(exact + "a")]).err().unwrap().kind(), + io::ErrorKind::InvalidInput + ); + assert!( + plan("app.exe", &[&"😀".repeat(16000)]).is_ok(), + "limit counts UTF-16 units, not UTF-8 bytes" + ); + assert_eq!( + plan("app.exe", &[&"😀".repeat(16400)]).err().unwrap().kind(), + io::ErrorKind::InvalidInput + ); + } +} diff --git a/crates/codexbar-providers/src/contained.rs b/crates/codexbar-providers/src/contained.rs index 23bae715..571f118b 100644 --- a/crates/codexbar-providers/src/contained.rs +++ b/crates/codexbar-providers/src/contained.rs @@ -16,16 +16,12 @@ pub(crate) struct Contained { _job: Option, } +#[cfg(not(windows))] impl Contained { - /// Ends the job's descendants and reaps the root. Unix callers stop their owned group before this call. + /// Reaps the Unix root after the caller stops its owned process group. pub(crate) fn stop(&mut self) -> std::io::Result { - #[cfg(windows)] - let job_result = self._job.as_ref().map_or(Ok(()), job::Job::terminate); let _ = self.child.kill(); - let status = self.child.wait(); - #[cfg(windows)] - job_result?; - status + self.child.wait() } } @@ -62,7 +58,7 @@ fn spawn_with(command: &mut Command, console: bool) -> std::io::Result Option { + pub fn kill_on_close(child: &impl std::os::windows::io::AsRawHandle) -> Option { // SAFETY: no name and default security; the handle is owned by the returned Job and closed on drop. let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()) }.ok()?; let job = Self(handle); diff --git a/crates/codexbar-providers/src/gemini_login.rs b/crates/codexbar-providers/src/gemini_login.rs index 00f69d41..a9994d9e 100644 --- a/crates/codexbar-providers/src/gemini_login.rs +++ b/crates/codexbar-providers/src/gemini_login.rs @@ -30,7 +30,7 @@ impl LoginWindow { // Own only the CLI root. A kill-on-close job would also capture the user's default browser, whose // existing windows and sessions must survive cancellation. No browser is controlled or inspected. command.creation_flags(0x0000_0010); - command.spawn() + crate::platform::spawn(&mut command) }; #[cfg(not(windows))] let process = crate::contained::spawn_console(&mut command); diff --git a/crates/codexbar-providers/src/platform.rs b/crates/codexbar-providers/src/platform.rs index d5398728..c6f51adf 100644 --- a/crates/codexbar-providers/src/platform.rs +++ b/crates/codexbar-providers/src/platform.rs @@ -2,6 +2,20 @@ use std::path::{Path, PathBuf}; +/// Serialize CodexBar-owned Windows launches while temporary inheritable standard handles exist. +/// Rust's private spawn mutex does not include native CreateProcessW callers. +pub fn spawn(command: &mut std::process::Command) -> std::io::Result { + #[cfg(windows)] + let _guard = spawn_guard(); + command.spawn() +} + +#[cfg(windows)] +pub(crate) fn spawn_guard() -> std::sync::MutexGuard<'static, ()> { + static LAUNCH: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LAUNCH.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// The user's home folder: `%USERPROFILE%` on Windows, `$HOME` elsewhere. pub fn home_dir() -> Option { codexbar_core::home_dir() @@ -128,13 +142,44 @@ pub fn open_url(url: &str) -> std::io::Result<()> { command.arg(url); command }; - command.spawn().map(|_| ()) + spawn(&mut command).map(|_| ()) } #[cfg(test)] mod tests { use super::*; + #[cfg(windows)] + #[test] + fn legacy_launch_waits_until_native_inheritable_handles_are_closed() { + use std::os::windows::process::CommandExt as _; + use std::time::Duration; + let guard = spawn_guard(); + let (started, ready) = std::sync::mpsc::channel(); + let (finished, result) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .arg("--list") + .creation_flags(0x0800_0000) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + started.send(()).unwrap(); + let mut child = spawn(&mut command).unwrap(); + finished.send(()).unwrap(); + assert!(child.wait().unwrap().success()); + }); + ready.recv_timeout(Duration::from_secs(3)).unwrap(); + assert_eq!( + result.recv_timeout(Duration::from_millis(200)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + ); + drop(guard); + result.recv_timeout(Duration::from_secs(3)).unwrap(); + worker.join().unwrap(); + } + /// A program file the system would run. fn put(path: &Path) { std::fs::write(path, "").unwrap(); diff --git a/crates/codexbar-providers/tests/command_runner.rs b/crates/codexbar-providers/tests/command_runner.rs index 012b8db6..bf8da200 100644 --- a/crates/codexbar-providers/tests/command_runner.rs +++ b/crates/codexbar-providers/tests/command_runner.rs @@ -1,4 +1,4 @@ -//! Credential-free native subprocess fixtures. No shell or installed provider CLI is used. +//! Credential-free native fixtures and a synthetic Windows batch shim. No installed provider CLI is used. use codexbar_providers::{CommandError, CommandRunner, SystemCommandRunner}; use std::fs::File; use std::io::{Read as _, Write as _}; @@ -34,6 +34,25 @@ fn hold(path: &Path) -> File { fn fixture(args: &[String]) -> ! { match args[0].as_str() { + #[cfg(windows)] + "window-parent" => windows_console::qualify(), + #[cfg(windows)] + "window-parent-observe" => windows_console::observe(), + #[cfg(windows)] + "window-observe" => windows_console::observe_from_no_console(), + #[cfg(windows)] + "window-binding" => windows_console::binding_from_no_console(), + #[cfg(windows)] + "window-parent-binding" => windows_console::binding_control(), + #[cfg(windows)] + "window-tree" | "window-raw-tree" => { + windows_console::tree(args[1].parse().unwrap(), args[0] == "window-raw-tree") + } + #[cfg(windows)] + "runner-exit" => windows_console::exit_with_owned_tree(Path::new(&args[1])), + #[cfg(windows)] + "lookup-parent" => windows_console::lookup(), + "argv" => println!("{}", serde_json::to_string(&args[1..]).unwrap()), "emit" => emit(&args[1], args[2].parse().unwrap()), "invalid" => std::io::stdout().write_all(&[0xff, b'a']).unwrap(), "utf8" => { @@ -47,6 +66,10 @@ fn fixture(args: &[String]) -> ! { "stdin" => assert_eq!(std::io::stdin().read(&mut [0u8; 1]).unwrap(), 0), "hang" | "overflow" => { let _held = hold(Path::new(&args[1])); + #[cfg(windows)] + if args.get(2).is_some_and(|arg| arg == "abrupt-exit") { + windows_console::register_abrupt_identity(Path::new(&args[1])); + } if args[0] == "overflow" { emit("both", LIMIT + 1); } @@ -58,9 +81,8 @@ fn fixture(args: &[String]) -> ! { let mut command = Command::new(std::env::current_exe().unwrap()); command.args(["hang", &args[1]]).stdin(Stdio::null()); #[cfg(windows)] - { - use std::os::windows::process::CommandExt as _; - command.creation_flags(0x0800_0000); // The fixture's descendant must stay hidden too. + if args.get(2).is_some_and(|arg| arg == "abrupt-exit") { + command.arg("abrupt-exit"); } if args[0] != "descendant" { command.stdout(Stdio::null()).stderr(Stdio::null()); @@ -204,13 +226,25 @@ fn main() { if !args.is_empty() { fixture(&args); } + #[cfg(windows)] + windows_console::no_console_parent(); + #[cfg(windows)] + windows_console::compatibility(); + #[cfg(windows)] + windows_sentinel::qualify(); let executable = std::env::current_exe().unwrap(); let program = executable.to_str().unwrap(); let run = |args: &[&str], timeout| SystemCommandRunner.run(program, args, timeout); let timeout = Duration::from_secs(3); - let control = Command::new(program) + let mut control = Command::new(program); + control .args(["emit", "stdout", "1048576"]) - .output() + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let control = codexbar_providers::platform::spawn(&mut control) + .unwrap() + .wait_with_output() .unwrap(); assert!(control.status.success()); assert_eq!(control.stdout.len(), MIB); @@ -263,14 +297,20 @@ fn main() { println!("qualify command fixture: {mode}"); let marker = scratch.marker(mode); let start = Instant::now(); - let result = run(&[mode, marker.to_str().unwrap()], timeout); + // Overflow moves more than 16 MiB through two streams. It is a size-cap test, not a 3 s hang test. + let deadline = if mode == "overflow" { + Duration::from_secs(15) + } else { + timeout + }; + let result = run(&[mode, marker.to_str().unwrap()], deadline); match mode { "overflow" => assert_eq!(result, Err(CommandError::Failed)), "detached-output" => assert!(result.unwrap().success), _ => assert_eq!(result, Err(CommandError::TimedOut)), } assert!( - start.elapsed() < timeout + Duration::from_secs(2), + start.elapsed() < deadline + Duration::from_secs(2), "deadline and cleanup must remain bounded" ); released(&marker); @@ -279,3 +319,11 @@ fn main() { "command runner: finite streams, cap boundary, overflow, timeout, descendant cleanup, exit status, UTF-8 and null stdin passed" ); } + +#[cfg(windows)] +#[path = "command_runner/windows_console.rs"] +mod windows_console; + +#[cfg(windows)] +#[path = "command_runner/windows_sentinel.rs"] +mod windows_sentinel; diff --git a/crates/codexbar-providers/tests/command_runner/windows_console.rs b/crates/codexbar-providers/tests/command_runner/windows_console.rs new file mode 100644 index 00000000..8a329813 --- /dev/null +++ b/crates/codexbar-providers/tests/command_runner/windows_console.rs @@ -0,0 +1,393 @@ +use super::*; +use windows::Win32::Foundation::{HANDLE, HWND, LPARAM}; +use windows::Win32::System::Console::{GetConsoleProcessList, GetConsoleWindow}; +use windows::Win32::System::StationsAndDesktops::{ + EnumDesktopWindows, GetThreadDesktop, GetUserObjectInformationW, UOI_NAME, +}; +use windows::Win32::System::Threading::GetCurrentThreadId; +use windows::Win32::UI::WindowsAndMessaging::{GetClassNameW, GetWindowThreadProcessId, IsWindow, IsWindowVisible}; +use windows::core::BOOL; + +#[path = "windows_console/abrupt.rs"] +mod abrupt; +#[path = "windows_console/diagnostics.rs"] +mod diagnostics; +#[path = "windows_console/hooks.rs"] +mod hooks; +#[path = "windows_console/identity.rs"] +mod identity; +#[path = "windows_console/observer.rs"] +mod observer; +#[path = "windows_console/raw.rs"] +mod raw; +#[path = "windows_console/station.rs"] +mod station; + +struct WindowSearch { + target: HWND, + found: bool, +} +unsafe extern "system" fn visit(window: HWND, data: LPARAM) -> BOOL { + // SAFETY: enumeration is synchronous; data points to the exclusive live WindowSearch below. + let search = unsafe { &mut *(data.0 as *mut WindowSearch) }; + search.found |= search.target == window; + true.into() +} + +fn enumerated(window: HWND) -> bool { + let mut search = WindowSearch { + target: window, + found: false, + }; + // SAFETY: only enumerate our current desktop; callback borrows this stack state synchronously. + unsafe { + EnumDesktopWindows( + Some(GetThreadDesktop(GetCurrentThreadId()).unwrap()), + Some(visit), + LPARAM(std::ptr::from_mut(&mut search) as isize), + ) + .unwrap(); + } + search.found +} + +fn desktop(thread: u32) -> String { + let mut name = [0u16; 256]; + // SAFETY: query a live thread's desktop into an exactly sized, writable UTF-16 buffer. + unsafe { + let handle = GetThreadDesktop(thread).unwrap(); + GetUserObjectInformationW( + HANDLE(handle.0), + UOI_NAME, + Some(name.as_mut_ptr().cast()), + std::mem::size_of_val(&name) as u32, + None, + ) + .unwrap(); + } + String::from_utf16(&name[..name.iter().position(|ch| *ch == 0).unwrap()]).unwrap() +} + +pub(super) fn tree(depth: u32, raw: bool) { + // Qualification-only registration/ACK and refreshed samples. Ordinary creation flags stay unchanged. + let _observed_lifetime = observer::client_enter(depth, raw); + // SAFETY: read-only queries of this fixture's actual console window and owning thread/process. + let window = unsafe { GetConsoleWindow() }; + let mut owner = 0; + let thread = unsafe { GetWindowThreadProcessId(window, Some(&mut owner)) }; + let mut members = [0; 16]; + let count = unsafe { GetConsoleProcessList(&mut members) } as usize; + let list_error = if count == 0 { + Some(unsafe { windows::Win32::Foundation::GetLastError() }.0) + } else { + None + }; + assert!(count <= members.len()); + let mut class = [0u16; 256]; + let length = unsafe { GetClassNameW(window, &mut class) } as usize; + let mut visible = false; + for _ in 0..5 { + visible |= unsafe { IsWindowVisible(window) }.as_bool(); + std::thread::sleep(Duration::from_millis(20)); + } + let row = serde_json::json!({ + "depth": depth, "pid": std::process::id(), "hwnd": window.0 as usize, + "visible": visible, "owner": owner, + "valid": unsafe { IsWindow(Some(window)) }.as_bool(), + "enumerated": enumerated(window), "members": members[..count], + "console_process_list_return": count, "console_process_list_error": list_error, + "class": String::from_utf16(&class[..length]).unwrap(), + "identity": identity::current(), "cwd": std::env::current_dir().unwrap(), + "station": station::current(), + "startup": raw::startup(), "route": if raw { "raw-handle-list" } else { "rust-command" }, + "inherited_env": std::env::var("CODEXBAR_SYNTHETIC_INHERITED").unwrap(), + "desktop": desktop(unsafe { GetCurrentThreadId() }), + "window_desktop": if thread == 0 { String::new() } else { desktop(thread) }, + }); + println!("{row}"); + std::io::stdout().flush().unwrap(); + if depth < 2 { + // Ordinary child and grandchild. No creation flags, startup hints, vendor settings or hiding. + if raw { + raw::child(depth + 1); + } else { + assert!( + Command::new(std::env::current_exe().unwrap()) + .args(["window-tree", &(depth + 1).to_string()]) + .status() + .unwrap() + .success() + ); + } + } +} + +pub(super) fn qualify() { + caller_precondition(); + for raw in [false, true] { + qualify_rows(&collect(raw)); + } +} + +fn collect(raw: bool) -> Vec { + let executable = std::env::current_exe().unwrap(); + let output = SystemCommandRunner + .run( + executable.to_str().unwrap(), + &[if raw { "window-raw-tree" } else { "window-tree" }, "0"], + Duration::from_secs(10), + ) + .unwrap(); + assert!(output.success, "native tree failed: {}", output.stderr); + let rows: Vec = output + .stdout + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + println!("ordinary descendant console observations: {}", output.stdout); + assert_eq!( + rows.len(), + 3, + "root, ordinary child and ordinary grandchild must report" + ); + rows +} + +fn qualify_rows(rows: &[serde_json::Value]) { + // This is candidate mechanism qualification, distinct from original-seam visible-window reproduction. + for row in rows { + assert_eq!( + row["visible"], false, + "background process allocated a visible console: {row}" + ); + } + for (depth, row) in rows.iter().enumerate() { + assert_eq!(row["depth"], depth); + assert_ne!( + row["hwnd"], 0, + "QUALIFICATION_PRECONDITION: no real console HWND; cannot prove hidden-console inheritance: {row}" + ); + assert_ne!(row["owner"], 0, "console owner must be known"); + assert_eq!(row["valid"], true); + assert_eq!( + row["enumerated"], true, + "message-only or foreign HWND is not window evidence: {row}" + ); + assert_eq!(row["class"], "ConsoleWindowClass"); + assert_eq!(row["hwnd"], rows[0]["hwnd"]); + assert_eq!(row["owner"], rows[0]["owner"]); + assert_eq!(row["desktop"], row["window_desktop"]); + assert_eq!(row["desktop"], rows[0]["desktop"]); + assert!(row["members"].as_array().unwrap().contains(&row["pid"])); + for ancestor in &rows[..depth] { + assert!(row["members"].as_array().unwrap().contains(&ancestor["pid"])); + } + assert_eq!( + row["identity"], + identity::current(), + "usage query must retain the caller's user/logon/session" + ); + assert_eq!(row["desktop"], desktop(unsafe { GetCurrentThreadId() })); + assert_eq!(row["cwd"], serde_json::json!(std::env::current_dir().unwrap())); + assert_eq!(row["inherited_env"], "native-fixture"); + } + let window = HWND(rows[0]["hwnd"].as_u64().unwrap() as *mut std::ffi::c_void); + let start = Instant::now(); + while unsafe { IsWindow(Some(window)) }.as_bool() { + assert!( + start.elapsed() < Duration::from_secs(3), + "the final console client exited but its window remained" + ); + std::thread::sleep(Duration::from_millis(10)); + } +} + +pub(super) fn observe() { + caller_precondition(); + observer::experiment(); +} + +pub(super) fn observe_from_no_console() { + // The outer inert fixture owns this kill-on-close job until process exit. A script watchdog killing + // this retained Popen identity therefore also closes its entire synthetic experiment, never global PIDs. + if let Err(error) = observer::own_experiment_job() { + println!( + "{}", + serde_json::json!({"result": "visibility-observer", "route": "raw-handle-list", + "ready": false, "classification": "unqualified", "full_observation": false, + "failure": error, "coverage": "unknown", "cleanup": "no child launched"}) + ); + return; + } + parent("window-parent-observe"); +} + +pub(super) fn no_console_parent() { + parent("window-parent"); +} + +pub(super) fn binding_from_no_console() { + if let Err(error) = observer::own_experiment_job() { + println!( + "{}", + serde_json::json!({"result": "owned-lifecycle-binding", "complete": false, + "failure": error, "cleanup_confirmed": false, "original_visibility_red": false}) + ); + return; + } + parent("window-parent-binding"); +} + +pub(super) fn binding_control() { + observer::validate_binding(); +} + +fn caller_precondition() { + let mut clients = [0; 16]; + let count = unsafe { GetConsoleProcessList(&mut clients) }; + let error = if count == 0 { + Some(unsafe { windows::Win32::Foundation::GetLastError() }.0) + } else { + None + }; + let window = unsafe { GetConsoleWindow() }; + println!( + "{}", + serde_json::json!({"result": "caller-precondition", "console_process_list_return": count, + "console_process_list_error": error, "query_succeeded": count != 0, + "creation_contract": "DETACHED_PROCESS on synthetic caller only", "requested_creation_flags": 8, + "startup": raw::startup(), "stdio": observer::stdio(), + "hwnd": window.0 as usize, "station": station::current(), "identity": identity::current()}) + ); + assert!(window.0.is_null()); + assert_eq!( + count, 0, + "detached caller unexpectedly has a successful console association query" + ); +} + +fn parent(mode: &str) { + use std::os::windows::process::CommandExt as _; + // DETACHED_PROCESS is the caller creation contract. A failed console query is reported as failure, + // not a successful empty membership list. The production runner configures only the provider root. + let scratch = Scratch::new(); + let mut command = Command::new(std::env::current_exe().unwrap()); + command.arg(mode); + if mode == "window-parent-observe" { + command.env(observer::DIRECTORY_ENV, &scratch.0); + } + let output = command + .env("CODEXBAR_SYNTHETIC_INHERITED", "native-fixture") + .creation_flags(0x0000_0008) // DETACHED_PROCESS, only on the synthetic caller. + .output() + .unwrap(); + print!("{}", String::from_utf8_lossy(&output.stdout)); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); +} + +fn handles() -> u32 { + use windows::Win32::System::Threading::{GetCurrentProcess, GetProcessHandleCount}; + let mut count = 0; + // SAFETY: query only this fixture process's handle count. + unsafe { GetProcessHandleCount(GetCurrentProcess(), &mut count) }.unwrap(); + count +} + +pub(super) fn compatibility() { + use std::os::windows::process::CommandExt as _; + let executable = std::env::current_exe().unwrap(); + let program = executable.to_str().unwrap(); + let timeout = Duration::from_secs(10); + let args = [ + "argv", + "-p", + "/usage", + "--output-format", + "json", + "--print-timeout", + "25s", + "", + "two words", + "a\"b", + "x\\\"y", + "ends with space \\", + "&|<>()^", + "%PATH%", + "!literal!", + "é漢字", + "tab\tvalue", + ]; + let native = SystemCommandRunner.run(program, &args, timeout).unwrap(); + assert!(native.success); + assert_eq!(serde_json::from_str::>(&native.stdout).unwrap(), args[1..]); + let scratch = Scratch::new(); + // An implicit cwd search would execute this invalid image instead of the fixture beside its parent. + std::fs::write( + scratch.marker(executable.file_name().unwrap().to_str().unwrap()), + b"not an executable", + ) + .unwrap(); + let lookup = Command::new(program) + .arg("lookup-parent") + .current_dir(&scratch.0) + .creation_flags(0x0800_0000) + .output() + .unwrap(); + assert!( + lookup.status.success(), + "bare-name resolver changed: {}", + String::from_utf8_lossy(&lookup.stderr) + ); + let shim = scratch.marker("native shim.cmd"); + std::fs::write(&shim, format!("@echo off\r\n@\"{program}\" argv %*\r\n")).unwrap(); + let batch_args = &args[1..]; + let expected = Command::new(&shim) + .args(batch_args) + .creation_flags(0x0800_0000) + .output() + .unwrap(); + assert!(expected.status.success()); + let batch = SystemCommandRunner + .run(shim.to_str().unwrap(), batch_args, timeout) + .unwrap(); + assert!(batch.success, "{}", batch.stderr); + assert_eq!( + batch.stdout.as_bytes(), + expected.stdout, + "batch shims must preserve Rust Command argument behavior" + ); + assert_eq!(batch.stderr.as_bytes(), expected.stderr); + let before = handles(); + for _ in 0..12 { + assert!(SystemCommandRunner.run(program, &["stdin"], timeout).unwrap().success); + } + assert_eq!( + handles(), + before, + "repeated queries must release process, thread, pipe and job handles" + ); + let marker = scratch.marker("parent-exit"); + abrupt::qualify(program, &marker); + println!("Windows native and batch arguments, repeated handle cleanup and abrupt-parent job cleanup passed"); +} + +pub(super) fn lookup() { + let executable = std::env::current_exe().unwrap(); + let name = executable.file_stem().unwrap().to_str().unwrap(); + let output = SystemCommandRunner + .run(name, &["argv", "lookup-ok"], Duration::from_secs(10)) + .unwrap(); + assert!(output.success); + assert_eq!( + serde_json::from_str::>(&output.stdout).unwrap(), + ["lookup-ok"] + ); +} + +pub(super) fn exit_with_owned_tree(marker: &Path) -> ! { + abrupt::exit_with_owned_tree(marker) +} + +pub(super) fn register_abrupt_identity(marker: &Path) { + abrupt::register_leaf(marker); +} diff --git a/crates/codexbar-providers/tests/command_runner/windows_console/abrupt.rs b/crates/codexbar-providers/tests/command_runner/windows_console/abrupt.rs new file mode 100644 index 00000000..6ce67c15 --- /dev/null +++ b/crates/codexbar-providers/tests/command_runner/windows_console/abrupt.rs @@ -0,0 +1,239 @@ +//! Abrupt runner death is asynchronous job cleanup. Retain the live leaf before permitting that death. +use super::*; +use serde_json::{Value, json}; +use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle}; +use std::os::windows::process::CommandExt as _; +use windows::Win32::Foundation::{FILETIME, GetLastError, WAIT_EVENT, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows::Win32::System::Threading::{ + GetCurrentProcess, GetProcessId, GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, TerminateProcess, WaitForSingleObject, +}; + +const SETUP: Duration = Duration::from_secs(5); // Existing fixture-start budget. +const CLEANUP: Duration = Duration::from_secs(3); // One existing budget, shared by all cleanup barriers. + +fn creation(process: HANDLE) -> u64 { + let (mut born, mut exit, mut kernel, mut user) = ( + FILETIME::default(), + FILETIME::default(), + FILETIME::default(), + FILETIME::default(), + ); + unsafe { GetProcessTimes(process, &mut born, &mut exit, &mut kernel, &mut user) }.unwrap(); + (u64::from(born.dwHighDateTime) << 32) | u64::from(born.dwLowDateTime) +} +fn publish(path: &Path, value: &Value) { + let mut staging = path.as_os_str().to_owned(); + staging.push(".tmp"); + std::fs::write(&staging, value.to_string()).unwrap(); + std::fs::rename(staging, path).unwrap(); +} +fn read(path: &Path) -> Value { + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() +} + +pub(super) fn register_leaf(marker: &Path) { + // Published by the same still-running process that owns the marker lock, not inferred after its death. + publish( + &marker.with_extension("identity"), + &json!({ + "pid": std::process::id(), "creation_time": creation(unsafe { GetCurrentProcess() }), + }), + ); +} + +struct Runner(std::process::Child); +impl Drop for Runner { + fn drop(&mut self) { + if !matches!(self.0.try_wait(), Ok(Some(_))) { + // Failure cleanup addresses only our retained Child handle; never a PID search/global kill. + if let Err(error) = self.0.kill() { + eprintln!("owned abrupt fixture cleanup request failed: {error}"); + } + } + } +} +struct Leaf { + process: OwnedHandle, + pid: u32, + born: u64, +} +impl Leaf { + fn retain(identity: &Value) -> Self { + let pid = u32::try_from(identity["pid"].as_u64().unwrap()).unwrap(); + let born = identity["creation_time"].as_u64().unwrap(); + let raw = unsafe { + OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, + false, + pid, + ) + } + .unwrap(); + let process = unsafe { OwnedHandle::from_raw_handle(raw.0) }; + let handle = HANDLE(process.as_raw_handle()); + assert!(born > 0, "leaf must publish a real creation timestamp"); + assert_eq!(unsafe { GetProcessId(handle) }, pid); + assert_eq!( + creation(handle), + born, + "ready PID must match the live leaf's creation identity" + ); + // Do not install terminating failure cleanup until the published identity is verified. + // A mismatched/reused PID above only closes the unverified handle; it never kills that process. + let leaf = Self { process, pid, born }; + leaf.assert_live(); + leaf + } + fn handle(&self) -> HANDLE { + HANDLE(self.process.as_raw_handle()) + } + fn assert_live(&self) { + assert_eq!( + state(self.handle(), 0), + WAIT_TIMEOUT, + "the exact retained leaf must be alive before abrupt-exit ACK" + ); + } +} +impl Drop for Leaf { + fn drop(&mut self) { + if unsafe { WaitForSingleObject(self.handle(), 0) } == WAIT_TIMEOUT { + // This is failed-test cleanup after the assertion, never evidence that job cleanup passed. + if let Err(error) = unsafe { TerminateProcess(self.handle(), 1) } { + eprintln!("owned abrupt leaf cleanup request failed: {error}"); + } + } + } +} +fn state(process: HANDLE, milliseconds: u32) -> WAIT_EVENT { + let state = unsafe { WaitForSingleObject(process, milliseconds) }; + if state == WAIT_FAILED { + panic!("retained process wait failed: {}", unsafe { GetLastError() }.0); + } + state +} +fn wait(process: HANDLE, deadline: Instant, description: &str) { + let remaining = deadline + .checked_duration_since(Instant::now()) + .expect("shared abrupt-parent cleanup budget expired"); + let milliseconds = u32::try_from(remaining.as_millis()).unwrap(); + assert_eq!( + state(process, milliseconds), + WAIT_OBJECT_0, + "{description} did not signal within the shared three-second budget" + ); + assert!( + Instant::now() <= deadline, + "shared abrupt-parent cleanup budget exceeded" + ); +} + +pub(super) fn qualify(program: &str, marker: &Path) { + let mut runner = Runner( + Command::new(program) + .args(["runner-exit", marker.to_str().unwrap()]) + .creation_flags(0x0800_0000) // Synthetic runner only. The actual provider launch is unchanged. + .spawn() + .unwrap(), + ); + let runner_handle = HANDLE(runner.0.as_raw_handle()); + let runner_born = creation(runner_handle); + let setup = Instant::now(); + while !marker.with_extension("identity").is_file() { + assert!( + setup.elapsed() < SETUP, + "leaf identity was not published before setup deadline" + ); + assert!( + runner.0.try_wait().unwrap().is_none(), + "runner exited before retaining its live leaf" + ); + std::thread::sleep(Duration::from_millis(5)); + } + let identity = read(&marker.with_extension("identity")); + let leaf = Leaf::retain(&identity); + let ready_pid: u32 = std::fs::read_to_string(marker.with_extension("ready")) + .unwrap() + .parse() + .unwrap(); + assert_eq!(ready_pid, leaf.pid); + let resource = File::open(marker).unwrap(); + assert!( + matches!(resource.try_lock(), Err(std::fs::TryLockError::WouldBlock)), + "leaf must own its resource before ACK" + ); + drop(resource); + leaf.assert_live(); + assert_eq!(creation(runner_handle), runner_born); + assert_eq!(state(runner_handle, 0), WAIT_TIMEOUT); + println!( + "abrupt-parent live identities before ACK: {}", + json!({ + "runner_pid": runner.0.id(), "runner_creation_time": runner_born, + "leaf_pid": leaf.pid, "leaf_creation_time": leaf.born, "retained_leaf_state": "WAIT_TIMEOUT", + }) + ); + // Budget starts before ACK, so runner exit, leaf termination and resource cleanup share one 3s bound. + let cleanup_started = Instant::now(); + let deadline = cleanup_started + CLEANUP; + publish(&marker.with_extension("exit-ack"), &identity); + wait(runner_handle, deadline, "abrupt runner"); + assert!(runner.0.wait().unwrap().success()); + wait(leaf.handle(), deadline, "owned leaf after kill-on-close job closure"); + assert_eq!(creation(leaf.handle()), leaf.born); + // Resource release is checked after actual process termination. It is never the liveness barrier. + let mut resource = File::open(marker).unwrap(); + loop { + match resource.try_lock() { + Ok(()) => break, + Err(std::fs::TryLockError::WouldBlock) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(5)) + } + Err(error) => { + panic!("terminated leaf did not release its resource within the same cleanup budget: {error}") + } + } + } + assert!(Instant::now() <= deadline); + let mut text = String::new(); + resource.read_to_string(&mut text).unwrap(); + assert_eq!(text, "ready"); + println!( + "abrupt-parent retained cleanup: {}", + json!({ + "runner_pid": runner.0.id(), "runner_creation_time": runner_born, + "leaf_pid": leaf.pid, "leaf_creation_time": leaf.born, "retained_exit_state": "WAIT_OBJECT_0", + "elapsed_us": cleanup_started.elapsed().as_micros(), "shared_budget_ms": CLEANUP.as_millis(), + "resource_released": true, + }) + ); +} + +pub(super) fn exit_with_owned_tree(marker: &Path) -> ! { + let runner_marker = marker.to_owned(); + std::thread::spawn(move || { + let executable = std::env::current_exe().unwrap(); + let _ = SystemCommandRunner.run( + executable.to_str().unwrap(), + &["descendant", runner_marker.to_str().unwrap(), "abrupt-exit"], + Duration::from_secs(60), + ); + }); + let start = Instant::now(); + while !marker.with_extension("identity").is_file() { + assert!(start.elapsed() < SETUP); + std::thread::sleep(Duration::from_millis(5)); + } + let identity = read(&marker.with_extension("identity")); + while !marker.with_extension("exit-ack").is_file() { + assert!( + start.elapsed() < SETUP, + "runner may not die until outer test retains and ACKs the leaf" + ); + std::thread::sleep(Duration::from_millis(5)); + } + assert_eq!(read(&marker.with_extension("exit-ack")), identity); + // No destructors run: Windows closing the production runner's job is responsible for the live leaf. + std::process::exit(0) +} diff --git a/crates/codexbar-providers/tests/command_runner/windows_console/binding.rs b/crates/codexbar-providers/tests/command_runner/windows_console/binding.rs new file mode 100644 index 00000000..5b62e4b5 --- /dev/null +++ b/crates/codexbar-providers/tests/command_runner/windows_console/binding.rs @@ -0,0 +1,760 @@ +//! Cooperative, owned-only native control. Its command/generation handoff is NOT a console/vendor contract. +//! The actual callback determines the event thread; no generator tuple is fabricated. +//! Notify only after SHOW: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-notifywinevent +//! Creator readback: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowthreadprocessid +use super::*; +#[path = "binding_policy.rs"] +mod policy; +use policy::{Actor, Binding, Registration, Ticket, Witness}; +use std::sync::Mutex; +use std::sync::mpsc::{Receiver, Sender}; +use windows::Win32::Foundation::WAIT_FAILED; +use windows::Win32::System::Threading::{ + GetCurrentThread, GetProcessIdOfThread, GetThreadTimes, THREAD_QUERY_LIMITED_INFORMATION, THREAD_SYNCHRONIZE, +}; +use windows::Win32::UI::Accessibility::NotifyWinEvent; +use windows::Win32::UI::WindowsAndMessaging::OBJID_WINDOW; + +fn thread_birth(handle: HANDLE) -> Result { + let (mut born, mut exit, mut kernel, mut user) = ( + FILETIME::default(), + FILETIME::default(), + FILETIME::default(), + FILETIME::default(), + ); + unsafe { GetThreadTimes(handle, &mut born, &mut exit, &mut kernel, &mut user) } + .map_err(|e| failure("GetThreadTimes owned control", e))?; + Ok(ticks(born)) +} +fn actor() -> Result { + Ok(Actor { + pid: std::process::id(), + process_birth: creation(unsafe { GetCurrentProcess() })?, + tid: unsafe { GetCurrentThreadId() }, + thread_birth: thread_birth(unsafe { GetCurrentThread() })?, + }) +} +fn actor_json(a: Actor) -> Value { + json!({"pid": a.pid, "process_birth": a.process_birth, "tid": a.tid, "thread_birth": a.thread_birth}) +} +fn progress_set(progress: &Mutex, key: &str, value: Value) -> Result<()> { + progress.lock().map_err(|_| "owned receipt mutex poisoned")?[key] = value; + Ok(()) +} +struct RetainedActor { + identity: Actor, + thread: OwnedHandle, + process: OwnedHandle, +} +impl RetainedActor { + fn retain(a: Actor) -> Result { + if a.pid != std::process::id() || a.process_birth != creation(unsafe { GetCurrentProcess() })? { + return Err("only this cooperative fixture process may register".into()); + } + let raw = unsafe { OpenThread(THREAD_QUERY_LIMITED_INFORMATION | THREAD_SYNCHRONIZE, false, a.tid) } + .map_err(|e| failure("OpenThread registered owned actor", e))?; + let thread = unsafe { OwnedHandle::from_raw_handle(raw.0) }; + let process = open(a.pid)?; + let retained = Self { + identity: a, + thread, + process, + }; + retained.live()?; + Ok(retained) + } + fn live(&self) -> Result { + let thread = HANDLE(self.thread.as_raw_handle()); + let process = HANDLE(self.process.as_raw_handle()); + for handle in [thread, process] { + let wait = unsafe { WaitForSingleObject(handle, 0) }; + if wait == WAIT_FAILED { + return Err(format!( + "owned actor WaitForSingleObject: {}", + unsafe { GetLastError() }.0 + )); + } + if wait != WAIT_TIMEOUT { + return Err("registered actor exited before cooperative handoff completed".into()); + } + } + let pid = unsafe { GetProcessIdOfThread(thread) }; + if pid == 0 { + return Err(format!( + "owned actor GetProcessIdOfThread: {}", + unsafe { GetLastError() }.0 + )); + } + let actual = Actor { + pid, + process_birth: creation(process)?, + tid: self.identity.tid, + thread_birth: thread_birth(thread)?, + }; + if actual != self.identity { + return Err("retained process/thread creation identity differs".into()); + } + Ok(actual) + } + fn wait_exited(&self, deadline: Instant) -> Result<()> { + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or("owned thread cleanup deadline")?; + let wait = unsafe { WaitForSingleObject(HANDLE(self.thread.as_raw_handle()), remaining.as_millis() as u32) }; + if wait == WAIT_FAILED { + return Err(format!( + "owned thread exit WaitForSingleObject: {}", + unsafe { GetLastError() }.0 + )); + } + if wait != WAIT_OBJECT_0 { + return Err("owned control thread did not terminate within fixture deadline".into()); + } + if thread_birth(HANDLE(self.thread.as_raw_handle()))? != self.identity.thread_birth { + return Err("thread creation identity changed after wait".into()); + } + Ok(()) + } +} +fn window_creator(hwnd: HWND, creator: &RetainedActor) -> Result { + let mut pid = 0; + let tid = unsafe { GetWindowThreadProcessId(hwnd, Some(&mut pid)) }; + if tid == 0 { + return Err(format!( + "live control GetWindowThreadProcessId: {}", + unsafe { GetLastError() }.0 + )); + } + let actor = creator.live()?; + if pid != actor.pid || tid != actor.tid { + return Err("window creator readback differs from retained owned actor".into()); + } + Ok(actor) +} +fn gone(hwnd: HWND) -> Result { + let mut pid = 0; + unsafe { SetLastError(WIN32_ERROR(0)) }; + let tid = unsafe { GetWindowThreadProcessId(hwnd, Some(&mut pid)) }; + let error = if tid == 0 { + Some(unsafe { GetLastError() }.0) + } else { + None + }; + if tid != 0 || pid != 0 || unsafe { IsWindow(Some(hwnd)) }.as_bool() { + return Err("controlled HWND destruction not independently confirmed".into()); + } + Ok(json!({"window_query_return": tid, "window_query_error": error, "is_window": false})) +} +fn receive(rx: &Receiver, deadline: Instant) -> Result { + loop { + if Instant::now() >= deadline { + return Err("owned lifecycle control deadline".into()); + } + match rx.try_recv() { + Ok(value) => return Ok(value), + Err(mpsc::TryRecvError::Disconnected) => return Err("owned control channel disconnected".into()), + Err(mpsc::TryRecvError::Empty) => {} + } + let mut message = MSG::default(); + let mut count = 0; + while unsafe { PeekMessageW(&mut message, None, 0, 0, PM_REMOVE) }.as_bool() { + count += 1; + if count > CAP { + return Err("control message cap".into()); + } + unsafe { DispatchMessageW(&message) }; + } + std::thread::sleep(Duration::from_millis(1)); + } +} +#[derive(Clone, Copy)] +enum Generate { + Notify { hwnd: u64, kind: u32 }, + Stop, +} +enum Request { + Register(Registration), + Arm(Ticket), + Pause, + Destroyed { nonce: u64, generation: u64 }, + Resume, + Finish, +} +enum Response { + Ack(&'static str), + Mapped(u32), + Finished(Value), + Failure(String), +} +fn response(rx: &Receiver, deadline: Instant, expected: &str) -> Result> { + match receive(rx, deadline)? { + Response::Ack(label) if label == expected => Ok(None), + Response::Mapped(0x8002) if expected == "show" => Ok(None), + Response::Mapped(0x8001) if expected == "destroy" => Ok(None), + Response::Finished(value) if expected == "finished" => Ok(Some(value)), + Response::Failure(error) => Err(error), + _ => Err("unexpected owned lifecycle protocol response".into()), + } +} +fn control_observer(start: Instant, expected: Arc, desktop: String) -> Result { + // Real resolver state; only bookkeeping initialization is shared in shape, no ownership decision is copied. + Ok(Observer { + directory: PathBuf::new(), + start, + anchor: std::process::id(), + anchor_born: creation(unsafe { GetCurrentProcess() })?, + session: session(std::process::id())?, + phase: Arc::new(AtomicU8::new(1)), + control_hwnd: expected, + stop: Arc::new(AtomicBool::new(false)), + processes: BTreeMap::new(), + events: Vec::new(), + errors: Vec::new(), + desktop, + control_seen: false, + max_gap_us: 0, + last: Instant::now(), + scans: 0, + cap_hit: false, + hook: None, + coverage_start: 0, + hook_control_seen: false, + hook_control_destroyed: false, + unresolved_events: 0, + unresolved_api_errors: BTreeMap::new(), + max_hook_lag_ms: 0, + known: BTreeMap::new(), + image: String::new(), + diagnostics: Diagnostics::new(), + }) +} +fn negatives(r: Registration, t: Ticket, w: Witness) -> Result { + let mut outcomes = Vec::new(); + for (label, ticket, witness) in [ + ( + "unknown-hwnd", + t, + Witness { + hwnd: r.hwnd.wrapping_add(1), + ..w + }, + ), + ( + "wrong-generation", + Ticket { + generation: r.generation.wrapping_add(1), + ..t + }, + w, + ), + ( + "wrong-nonce", + Ticket { + nonce: r.nonce.wrapping_add(1), + ..t + }, + w, + ), + ( + "wrong-thread-birth", + t, + Witness { + generator: Actor { + thread_birth: w.generator.thread_birth.wrapping_add(1), + ..w.generator + }, + ..w + }, + ), + ( + "wrong-generator", + t, + Witness { + generator: r.creator, + ..w + }, + ), + ( + "missing-live-owner", + t, + Witness { + live_creator: None, + ..w + }, + ), + ] { + let rejected = Binding::register(r, r.creator) + .map_err(str::to_string)? + .map(ticket, witness) + .is_err(); + if !rejected { + return Err(format!("cooperative negative control accepted: {label}")); + } + outcomes.push(json!({"case": label, "rejected": rejected})); + } + Ok( + json!({"basis": "policy perturbations of a measured owned tuple, not observed OS events or recycled HWNDs", "outcomes": outcomes}), + ) +} +fn worker( + start: Instant, + expected: Arc, + requests: Receiver, + tx: &Sender, + ready: &mpsc::SyncSender>, + progress: &Mutex, +) -> Result<()> { + let desktop = thread_desktop(unsafe { GetCurrentThreadId() })?; + let input = input_desktop()?; + let station = station::current(); + let session = session(std::process::id())?; + progress_set( + progress, + "ready_context", + json!({"desktop": desktop, "input_desktop": input, "session": session, "station": station}), + )?; + if desktop != input || station["visible"] != true || session == 0 { + return Err("binding observer is not on the interactive input desktop".into()); + } + let mut hook = super::super::hooks::Hook::install()?; + let mut resolver = control_observer(start, expected, desktop.clone())?; + let mut anonymous_foreign = super::super::hooks::pump()?.len(); + let mut event_count = anonymous_foreign; + ready.send(Ok(actor()?)).map_err(|e| e.to_string())?; + let mut registration: Option = None; + let mut actors: Option<(RetainedActor, RetainedActor)> = None; + let mut binding: Option = None; + let mut pending: Option = None; + let mut paused = true; // Events remain queued until live registration is read back independently. + let mut events = Vec::new(); + let mut show = false; + let mut destroy = false; + let mut legacy_show_rejected = false; + let mut legacy_destroy_accepted = false; + let mut native_wrong_generator_rejected = false; + let mut unknown_for_registered_hwnd = 0; + let mut negative = Value::Null; + let mut destroyed_readback = Value::Null; + while start.elapsed() < Duration::from_secs(10) { + match requests.try_recv() { + Ok(Request::Register(r)) => { + if registration.is_some() { + return Err("window generation was registered twice".into()); + } + let creator = RetainedActor::retain(r.creator)?; + let generator = RetainedActor::retain(r.generator)?; + let readback = window_creator(HWND(r.hwnd as *mut _), &creator)?; + binding = Some(Binding::register(r, readback).map_err(str::to_string)?); + resolver.record(HWND(r.hwnd as *mut _))?; + progress_set( + progress, + "independent_registration_readback", + json!({"creator": actor_json(readback), + "generator": actor_json(generator.live()?), "window": resolver.events.last()}), + )?; + registration = Some(r); + actors = Some((creator, generator)); + paused = false; + tx.send(Response::Ack("registered")).map_err(|e| e.to_string())?; + } + Ok(Request::Arm(ticket)) => { + if pending.is_some() { + return Err("overlapping cooperative notifications".into()); + } + pending = Some(ticket); + tx.send(Response::Ack("armed")).map_err(|e| e.to_string())?; + } + Ok(Request::Pause) => { + paused = true; + tx.send(Response::Ack("paused")).map_err(|e| e.to_string())?; + } + Ok(Request::Destroyed { nonce, generation }) => { + if !paused { + return Err("destroy handoff must precede delayed event delivery".into()); + } + let r = registration.ok_or("destroy without registration")?; + destroyed_readback = gone(HWND(r.hwnd as *mut _))?; + progress_set(progress, "destroy_readback", destroyed_readback.clone())?; + binding + .as_mut() + .ok_or("missing cooperative registry")? + .confirm_destroyed(nonce, generation) + .map_err(str::to_string)?; + tx.send(Response::Ack("destroyed")).map_err(|e| e.to_string())?; + } + Ok(Request::Resume) => { + paused = false; + tx.send(Response::Ack("resumed")).map_err(|e| e.to_string())?; + } + Ok(Request::Finish) => { + let (creator, generator) = actors.as_ref().ok_or("missing retained actors")?; + creator.live()?; + generator.live()?; + if !show + || !destroy + || !legacy_show_rejected + || !legacy_destroy_accepted + || !native_wrong_generator_rejected + || unknown_for_registered_hwnd != 0 + { + return Err("binding validation incomplete; no original/candidate pair permitted".into()); + } + let tail = super::super::hooks::pump()?; + event_count += tail.len(); + progress_set(progress, "event_count", json!(event_count))?; + if event_count > CAP { + progress_set(progress, "event_cap_hit", json!(true))?; + return Err("owned binding cleanup event cap exceeded".into()); + } + let registered_hwnd = registration.ok_or("missing registration at cleanup")?.hwnd; + if tail.iter().any(|event| event.hwnd.0 as u64 == registered_hwnd) { + return Err("registered numeric HWND event remained queued at binding cleanup".into()); + } + anonymous_foreign += tail.len(); + hook.close()?; + let result = json!({"events": events, "native_show_mapped": show, "native_delayed_destroy_mapped": destroy, + "legacy_resolver_show_rejected": legacy_show_rejected, "legacy_resolver_destroy_accepted": legacy_destroy_accepted, + "native_wrong_generator_rejected": native_wrong_generator_rejected, + "destroy_readback": destroyed_readback, "negative_policy_controls": negative, + "anonymous_foreign_events": anonymous_foreign, "unattributable_registered_numeric_hwnd_events": unknown_for_registered_hwnd, + "event_count": event_count, "event_cap": CAP, "event_cap_hit": false, + "hook_closed": true, "event_loss": false, "queue_drained": true, "desktop": desktop, + "window_generation_source": "explicit fixture registration and serialized command handoff only", + "provider_or_broker_generation_proof": false}); + { + let mut saved = progress.lock().map_err(|_| "owned receipt mutex poisoned")?; + for (key, value) in result.as_object().ok_or("invalid result receipt")? { + saved[key] = value.clone(); + } + } + tx.send(Response::Finished( + progress.lock().map_err(|_| "owned receipt mutex poisoned")?.clone(), + )) + .map_err(|e| e.to_string())?; + return Ok(()); + } + Err(mpsc::TryRecvError::Disconnected) => return Err("binding controller exited".into()), + Err(mpsc::TryRecvError::Empty) => {} + } + if !paused { + let r = registration.ok_or("pump without owned registration")?; + let (creator, generator) = actors.as_ref().ok_or("missing retained actors")?; + let batch = super::super::hooks::pump()?; + event_count += batch.len(); + progress_set(progress, "event_count", json!(event_count))?; + if event_count > CAP { + progress_set(progress, "event_cap_hit", json!(true))?; + return Err("owned binding total event cap exceeded".into()); + } + for event in batch { + if event.hwnd.0 as u64 != r.hwnd { + anonymous_foreign += 1; + progress_set(progress, "anonymous_foreign_events", json!(anonymous_foreign))?; + continue; + } + if event.thread != r.creator.tid && event.thread != r.generator.tid { + unknown_for_registered_hwnd += 1; + progress_set( + progress, + "unattributable_registered_numeric_hwnd_events", + json!(unknown_for_registered_hwnd), + )?; + continue; + } + let creator_readback = creator.live()?; + let generator_readback = generator.live()?; + if events.len() >= 64 { + return Err("owned binding event cap".into()); + } + progress_set( + progress, + "last_owned_hook_tuple_before_resolution", + json!({"hwnd": r.hwnd, + "creator": actor_json(r.creator), "registered_generator": actor_json(r.generator), + "actual_event_thread": event.thread, "kind": event.kind, "callback_sequence": event.sequence, + "generated_ms": event.generated_ms, "received_ms": event.received_ms, + "fixture_generation": r.generation, "cooperative_mapping": "pending", "original_visibility_red": false}), + )?; + // Exercise the SAME resolver body used by the provider observer, with a real hook tuple. + let old = resolver.resolve_events(vec![event]); + progress_set(progress, "last_existing_resolver_error", json!(old.as_ref().err()))?; + let mut mapping = "no-cooperative-ticket-for-this-generator"; + if let Some(ticket) = pending + && event.kind == ticket.kind + && event.thread + == if ticket.kind == EVENT_OBJECT_DESTROY { + r.creator.tid + } else { + r.generator.tid + } + { + let live_creator = if event.kind == EVENT_OBJECT_DESTROY { + gone(event.hwnd)?; + None + } else { + Some(window_creator(event.hwnd, creator)?) + }; + let witness = Witness { + hwnd: r.hwnd, + kind: event.kind, + callback_sequence: event.sequence, + generator: if event.thread == r.generator.tid { + generator_readback + } else { + creator_readback + }, + live_creator, + }; + if event.kind == EVENT_OBJECT_SHOW { + negative = negatives(r, ticket, witness)?; + progress_set(progress, "negative_policy_controls", negative.clone())?; + } + binding + .as_mut() + .ok_or("missing binding")? + .map(ticket, witness) + .map_err(str::to_string)?; + if event.kind == EVENT_OBJECT_SHOW { + if old.as_ref().err().map(String::as_str) + != Some("WinEvent identity differs from retained window owner/thread") + { + return Err( + "real resolver did not reach the expected creator/generator identity rejection".into(), + ); + } + show = true; + legacy_show_rejected = true; + } else if event.kind == EVENT_OBJECT_DESTROY { + if old.is_err() { + return Err("existing resolver rejected the retained creator's delayed DESTROY".into()); + } + destroy = true; + legacy_destroy_accepted = true; + } + mapping = "cooperative-fixture-command-generation-only"; + pending = None; + tx.send(Response::Mapped(event.kind)).map_err(|e| e.to_string())?; + } else { + if old.is_err() { + return Err("creator-generated native control event was rejected".into()); + } + if let Some(ticket) = pending + && event.kind == ticket.kind + { + let wrong_source = Witness { + hwnd: r.hwnd, + kind: event.kind, + callback_sequence: event.sequence, + generator: creator_readback, + live_creator: Some(window_creator(event.hwnd, creator)?), + }; + if binding.as_mut().ok_or("missing binding")?.map(ticket, wrong_source) + != Err("wrong-generator-identity") + { + return Err("real creator event was not rejected for the separate-generator ticket".into()); + } + native_wrong_generator_rejected = true; + mapping = "rejected-real-creator-event-for-separate-generator-ticket"; + progress_set(progress, "native_wrong_generator_rejected", json!(true))?; + } + } + events.push(json!({"phase": "owned-lifecycle-binding-control", "hwnd": r.hwnd, "creator_tid": r.creator.tid, + "generator_tid": event.thread, "generator_thread_birth": if event.thread == r.generator.tid { r.generator.thread_birth } else { r.creator.thread_birth }, + "registered_pid": r.creator.pid, "process_birth": r.creator.process_birth, + "fixture_generation": r.generation, "event_kind": event.kind, "event_generated_ms": event.generated_ms, + "event_received_ms": event.received_ms, "callback_sequence": event.sequence, "mapping": mapping, + "existing_resolver_error": old.err(), "original_visibility_red": false})); + progress_set(progress, "events", json!(events))?; + } + } + std::thread::sleep(Duration::from_millis(1)); + } + Err("owned binding fixture exceeded ten-second deadline".into()) +} + +pub(super) fn run() { + let mut report = json!({"result": "owned-lifecycle-binding", "complete": false, "failure": null, + "original_visibility_red": false, "scope": "cooperative-owned-fixture-only", "provider_or_broker_generation_proof": false, + "cleanup_confirmed": false}); + let progress = Arc::new(Mutex::new(json!({"phase": "owned-lifecycle-binding-control", + "events": [], "provider_or_broker_generation_proof": false, "hook_closed": false, + "event_loss": "unknown", "queue_drained": false, "event_count": 0, "event_cap": CAP, + "event_cap_hit": false, "coverage": "incomplete until cooperative receipt and retained cleanup"}))); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<()> { + let start = Instant::now(); + let deadline = start + Duration::from_secs(10); + let creator = actor()?; + report["creator"] = actor_json(creator); + let retained_creator = RetainedActor::retain(creator)?; + let mut members = [0; 16]; + let count = unsafe { GetConsoleProcessList(&mut members) }; + let console_error = if count == 0 { + Some(unsafe { GetLastError() }.0) + } else { + None + }; + let console_hwnd = unsafe { GetConsoleWindow() }.0 as usize; + report["caller_console_query"] = json!({"return": count, "error": console_error, "success": count != 0, + "console_hwnd": console_hwnd, "creation_contract": "DETACHED_PROCESS synthetic caller"}); + if count != 0 || console_hwnd != 0 { + return Err("binding control caller must be detached/consoleless by creation contract".into()); + } + let expected = Arc::new(AtomicUsize::new(0)); + let (requests_tx, requests_rx) = mpsc::channel(); + let (responses_tx, responses_rx) = mpsc::channel(); + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let control = expected.clone(); + let worker_progress = progress.clone(); + let observer_thread = std::thread::spawn(move || { + let result = worker(start, control, requests_rx, &responses_tx, &ready_tx, &worker_progress); + if let Err(error) = &result { + let _ = ready_tx.try_send(Err(error.clone())); + let _ = responses_tx.send(Response::Failure(error.clone())); + } + result + }); + let observer_actor = receive(&ready_rx, (start + READY).min(deadline))??; + let observer_retained = RetainedActor::retain(observer_actor)?; + report["ready_us"] = json!(start.elapsed().as_micros()); + let (generate_tx, generate_rx) = mpsc::channel(); + let (generated_tx, generated_rx) = mpsc::channel(); + let (actor_tx, actor_rx) = mpsc::sync_channel(1); + let generator_thread = std::thread::spawn(move || -> Result<()> { + actor_tx.send(actor()?).map_err(|e| e.to_string())?; + while let Ok(command) = generate_rx.recv_timeout(Duration::from_secs(10)) { + match command { + Generate::Stop => return Ok(()), + Generate::Notify { hwnd, kind } => { + // Controller has already performed the real SHOW. Notify only afterward. + unsafe { NotifyWinEvent(kind, HWND(hwnd as *mut _), OBJID_WINDOW.0, 0) }; + generated_tx.send(actor()?).map_err(|e| e.to_string())?; + } + } + } + Err("owned generator lost its controller".into()) + }); + let generator = receive(&actor_rx, (Instant::now() + READY).min(deadline))?; + let generator_retained = RetainedActor::retain(generator)?; + if creator.tid == generator.tid { + return Err("native generator must differ from creator".into()); + } + report["creator"] = actor_json(creator); + report["generator"] = actor_json(generator); + report["observer"] = actor_json(observer_actor); + let window_start = Instant::now(); + let window_deadline = (window_start + Duration::from_secs(1)).min(deadline); + let hwnd = unsafe { + CreateWindowExW( + WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW, + w!("STATIC"), + w!(""), + WS_POPUP, + 0, + 0, + 16, + 16, + None, + None, + None, + None, + ) + } + .map_err(|e| failure("binding CreateWindowExW", e))?; + let window = Control(hwnd); + expected.store(hwnd.0 as usize, Ordering::SeqCst); + let mut random = [0u8; 8]; + getrandom::fill(&mut random).map_err(|e| e.to_string())?; + let nonce = u64::from_le_bytes(random).max(1); + let registration = Registration { + hwnd: hwnd.0 as u64, + nonce, + generation: 1, + creator, + generator, + }; + window_creator(hwnd, &retained_creator)?; + report["registration"] = json!({"hwnd": registration.hwnd, "nonce": nonce, "fixture_generation": 1, "creator_api_readback_matches": true}); + requests_tx + .send(Request::Register(registration)) + .map_err(|e| e.to_string())?; + response(&responses_rx, window_deadline, "registered")?; + requests_tx + .send(Request::Arm(Ticket { + nonce, + generation: 1, + command: 1, + kind: EVENT_OBJECT_SHOW, + })) + .map_err(|e| e.to_string())?; + response(&responses_rx, window_deadline, "armed")?; + let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) }; + let visible = unsafe { IsWindowVisible(hwnd) }.as_bool(); + report["visible_control_readback"] = json!(visible); + if !visible { + return Err("native binding visible control was not visible".into()); + } + generate_tx + .send(Generate::Notify { + hwnd: registration.hwnd, + kind: EVENT_OBJECT_SHOW, + }) + .map_err(|e| e.to_string())?; + if receive(&generated_rx, window_deadline)? != generator { + return Err("actual SHOW generator identity changed".into()); + } + response(&responses_rx, window_deadline, "show")?; + requests_tx.send(Request::Pause).map_err(|e| e.to_string())?; + response(&responses_rx, window_deadline, "paused")?; + requests_tx + .send(Request::Arm(Ticket { + nonce, + generation: 1, + command: 2, + kind: EVENT_OBJECT_DESTROY, + })) + .map_err(|e| e.to_string())?; + response(&responses_rx, window_deadline, "armed")?; + unsafe { DestroyWindow(hwnd) }.map_err(|e| failure("binding DestroyWindow creator", e))?; + std::mem::forget(window); // Never revisit this destroyed/recyclable HWND in Drop. + report["window_lifetime_us"] = json!(window_start.elapsed().as_micros()); + if Instant::now() >= window_deadline { + return Err("binding visible control exceeded one-second lifetime".into()); + } + report["creator_destroy_readback"] = gone(hwnd)?; + requests_tx + .send(Request::Destroyed { nonce, generation: 1 }) + .map_err(|e| e.to_string())?; + response(&responses_rx, deadline, "destroyed")?; + // Only the creator calls DestroyWindow. Its real USER DESTROY notification remains queued + // until after the creator and observer independently confirm the HWND is already invalid. + requests_tx.send(Request::Resume).map_err(|e| e.to_string())?; + response(&responses_rx, deadline, "resumed")?; + response(&responses_rx, deadline, "destroy")?; + requests_tx.send(Request::Finish).map_err(|e| e.to_string())?; + report["observation"] = response(&responses_rx, deadline, "finished")?.ok_or("missing control receipt")?; + observer_retained.wait_exited(deadline)?; + observer_thread.join().map_err(|_| "observer thread panicked")??; + generate_tx.send(Generate::Stop).map_err(|e| e.to_string())?; + generator_retained.wait_exited(deadline)?; + generator_thread.join().map_err(|_| "generator thread panicked")??; + retained_creator.live()?; + report["cleanup_confirmed"] = json!(true); + report["complete"] = json!(true); + report["elapsed_us"] = json!(start.elapsed().as_micros()); + report["cleanup"] = json!({"window_destroyed": true, "generator_retained_exit": true, "observer_retained_exit": true, + "creator_retained_live_to_receipt": true, "enclosing_process_exit": "waited by outer native fixture launcher"}); + Ok(()) + })); + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => report["failure"] = json!(error), + Err(_) => report["failure"] = json!("owned lifecycle control panicked"), + } + if report["observation"].is_null() { + report["observation"] = progress + .lock() + .map(|saved| saved.clone()) + .unwrap_or_else(|_| json!({"failure": "receipt mutex poisoned"})); + } + println!("{report}"); +} diff --git a/crates/codexbar-providers/tests/command_runner/windows_console/binding_policy.rs b/crates/codexbar-providers/tests/command_runner/windows_console/binding_policy.rs new file mode 100644 index 00000000..c4e26550 --- /dev/null +++ b/crates/codexbar-providers/tests/command_runner/windows_console/binding_policy.rs @@ -0,0 +1,99 @@ +//! Cooperative fixture protocol only. A WinEvent does NOT carry this generation/command witness. +//! No caller may use this policy to attribute an unregistered provider or broker window. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Actor { + pub pid: u32, + pub process_birth: u64, + pub tid: u32, + pub thread_birth: u64, +} +#[derive(Clone, Copy, Debug)] +pub struct Registration { + pub hwnd: u64, + pub nonce: u64, + pub generation: u64, + pub creator: Actor, + pub generator: Actor, +} +#[derive(Clone, Copy, Debug)] +pub struct Ticket { + pub nonce: u64, + pub generation: u64, + pub command: u64, + pub kind: u32, +} +#[derive(Clone, Copy, Debug)] +pub struct Witness { + pub hwnd: u64, + pub kind: u32, + pub callback_sequence: u64, + pub generator: Actor, // Read back from the retained live thread, not guessed from a TID. + pub live_creator: Option, +} +pub struct Binding { + registration: Registration, + destroyed: bool, + last_command: u64, + last_callback: u64, +} +impl Binding { + pub fn register(registration: Registration, readback: Actor) -> Result { + let Registration { + hwnd, + nonce, + generation, + creator, + generator, + } = registration; + if hwnd == 0 || nonce == 0 || generation == 0 || creator != readback { + return Err("invalid-window-registration"); + } + if [creator, generator] + .iter() + .any(|a| a.pid == 0 || a.process_birth == 0 || a.tid == 0 || a.thread_birth == 0) + || creator.pid != generator.pid + || creator.process_birth != generator.process_birth + || creator.tid == generator.tid + { + return Err("invalid-retained-actors"); + } + Ok(Self { + registration, + destroyed: false, + last_command: 0, + last_callback: 0, + }) + } + pub fn confirm_destroyed(&mut self, nonce: u64, generation: u64) -> Result<(), &'static str> { + if nonce != self.registration.nonce || generation != self.registration.generation || self.destroyed { + return Err("invalid-cooperative-destroy-handoff"); + } + self.destroyed = true; + Ok(()) + } + pub fn map(&mut self, ticket: Ticket, witness: Witness) -> Result<(), &'static str> { + let r = self.registration; + if ticket.nonce != r.nonce || ticket.generation != r.generation || witness.hwnd != r.hwnd { + return Err("unknown-or-stale-generation"); + } + // The separate owned thread announces SHOW; the creator performs the real DestroyWindow. + let expected_generator = if witness.kind == 0x8001 { r.creator } else { r.generator }; + if witness.generator != expected_generator { + return Err("wrong-generator-identity"); + } + if ticket.command != self.last_command + 1 + || witness.callback_sequence <= self.last_callback + || ticket.kind != witness.kind + { + return Err("missing-or-replayed-command-event"); + } + match witness.kind { + 0x8002 if !self.destroyed && witness.live_creator == Some(r.creator) => {} + 0x8001 if self.destroyed && witness.live_creator.is_none() => {} + _ => return Err("missing-live-or-destroy-handoff-metadata"), + } + self.last_command = ticket.command; + self.last_callback = witness.callback_sequence; + Ok(()) + } +} diff --git a/crates/codexbar-providers/tests/command_runner/windows_console/diagnostics.rs b/crates/codexbar-providers/tests/command_runner/windows_console/diagnostics.rs new file mode 100644 index 00000000..ba340bc0 --- /dev/null +++ b/crates/codexbar-providers/tests/command_runner/windows_console/diagnostics.rs @@ -0,0 +1,258 @@ +//! Pure measurement policy. This module never resolves ownership or changes qualification. +//! Raw unresolved tuples are intentionally unrepresentable in the serialized anonymous categories. +use serde_json::{Value, json}; +use std::collections::BTreeMap; + +const CAP: usize = 64; + +#[derive(Clone, Copy)] +pub struct Event { + pub hwnd: u64, + pub thread: u32, + pub kind: u32, + pub generated_ms: u32, + pub received_ms: u64, + pub callback_sequence: u64, +} + +/// Supplied only AFTER independent window queries match a retained owned process identity. +#[derive(Clone, Copy)] +pub struct OwnedObservation { + pub hwnd: u64, + pub pid: u32, + pub birth: u64, + pub creator_thread: u32, + pub received_ms: u64, + pub evidence_event_index: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Stage { + WindowOwner, + ThreadOpen, + ThreadOwner, + CacheFallback, + OwnedFilter, + Metadata, + Identity, + EventCap, + Accepted, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Reason { + Unresolved, + OutsideOwned, + NotTopLevel, + QueryFailed, + MissingMetadata, + IdentityMismatch, + CapReached, + Accepted, +} + +#[derive(Clone, Copy)] +pub struct Decision { + pub stage: Stage, + pub reason: Reason, + pub unresolved_before: usize, + pub unresolved_after: usize, + pub cached_creator_thread_match: Option, + pub observation_sequence_before_resolution: u64, + /// Fresh record() result for THIS decision, never a token obtained just by cached HWND/time matching. + pub independent_observation: Option, +} + +#[derive(Clone, Copy)] +struct History { + observation: OwnedObservation, + epoch: u64, + first_sequence: u64, + last_sequence: u64, + first_ms: u64, +} +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +struct Category { + kind: &'static str, + cache: &'static str, + thread_match: Option, + order: &'static str, + stage: Stage, + reason: Reason, +} + +pub struct Diagnostics { + history: BTreeMap, + categories: BTreeMap, + validated_events: Vec, + sequence: u64, + epoch: u64, + decisions: u64, + unresolved_decisions: u64, + last_unresolved_after: usize, + counter_order_valid: bool, + truncated: bool, + invalid_observations: u64, +} +impl Diagnostics { + pub fn new() -> Self { + Self { + history: BTreeMap::new(), + categories: BTreeMap::new(), + validated_events: Vec::new(), + sequence: 0, + epoch: 0, + decisions: 0, + unresolved_decisions: 0, + last_unresolved_after: 0, + counter_order_valid: true, + truncated: false, + invalid_observations: 0, + } + } + pub fn sequence(&self) -> u64 { + self.sequence + } + pub fn observe_owned(&mut self, observation: OwnedObservation) { + if observation.hwnd == 0 || observation.pid == 0 || observation.birth == 0 || observation.creator_thread == 0 { + self.invalid_observations += 1; + return; + } + self.sequence += 1; + if !self.history.contains_key(&observation.hwnd) && self.history.len() == CAP { + self.truncated = true; + return; + } + let previous = self.history.get(&observation.hwnd); + let same_identity = previous.is_some_and(|p| { + p.observation.pid == observation.pid + && p.observation.birth == observation.birth + && p.observation.creator_thread == observation.creator_thread + }); + if !same_identity { + self.epoch += 1; + } + let (epoch, first_sequence, first_ms) = previous + .filter(|_| same_identity) + .map(|p| (p.epoch, p.first_sequence, p.first_ms)) + .unwrap_or((self.epoch, self.sequence, observation.received_ms)); + self.history.insert( + observation.hwnd, + History { + observation, + epoch, + first_sequence, + last_sequence: self.sequence, + first_ms, + }, + ); + } + pub fn record(&mut self, event: Event, decision: Decision) { + self.decisions += 1; + if decision.reason == Reason::Unresolved { + self.unresolved_decisions += 1; + self.counter_order_valid &= decision.unresolved_after == decision.unresolved_before.saturating_add(1) + && decision.unresolved_before == self.last_unresolved_after; + self.last_unresolved_after = decision.unresolved_after; + } else { + self.counter_order_valid &= decision.unresolved_after == decision.unresolved_before; + } + let history = self.history.get(&event.hwnd); + let category = Category { + kind: match event.kind { + 0x8000 => "create", + 0x8001 => "destroy", + 0x8002 => "show", + 0x8003 => "hide", + _ => "other", + }, + cache: match (decision.cached_creator_thread_match, history) { + (None, _) => "miss", + (Some(_), Some(_)) => "independently-validated-owned-history", + (Some(_), None) => "owned-history-unavailable-at-diagnostic-cap", + }, + thread_match: decision.cached_creator_thread_match, + order: history.map_or("no-owned-history", |h| order(event, h)), + stage: decision.stage, + reason: decision.reason, + }; + if let Some(count) = self.categories.get_mut(&category) { + *count += 1; + } else if self.categories.len() < CAP { + self.categories.insert(category, 1); + } else { + self.truncated = true; + } + + // Unknown, stale, cache-only, foreign and mismatched events NEVER serialize raw tuple fields. + // Even a matching HWND/thread/time cannot mint this fresh independent-observation token. + if decision.reason == Reason::Accepted + && let Some(h) = history + && decision.cached_creator_thread_match == Some(true) + && decision.independent_observation == Some(h.last_sequence) + && h.last_sequence > decision.observation_sequence_before_resolution + && h.first_sequence <= decision.observation_sequence_before_resolution + && h.observation.creator_thread == event.thread + { + if self.validated_events.len() == CAP { + self.truncated = true; + return; + } + self.validated_events.push(json!({ + "cache_validation": "fresh-independent-owned-window-observation-at-event-processing", + "event_ownership_proof": false, "hwnd": h.observation.hwnd, + "retained_pid": h.observation.pid, "retained_process_birth": h.observation.birth, + "creator_thread": h.observation.creator_thread, "event_thread": event.thread, + "event_kind": event.kind, "event_generated_ms": event.generated_ms, + "event_received_ms": event.received_ms, "callback_sequence": event.callback_sequence, + "observed_identity_epoch": h.epoch, "observation_sequence": h.last_sequence, + "evidence_event_index": h.observation.evidence_event_index, + "complete_hwnd_generation_proof": false, + })); + } + } + pub fn report(&self) -> Value { + let anonymous: Vec<_> = self.categories.iter().map(|(key, count)| json!({ + "event_kind": key.kind, "cache": key.cache, "cached_creator_thread_match": key.thread_match, + "event_ownership_proof": false, + "generation_time_order_vs_owned_observations": key.order, + "failure_stage": format!("{:?}", key.stage), "decision_reason": format!("{:?}", key.reason), "count": count, + })).collect(); + let history: Vec<_> = self.history.values().map(|h| json!({ + "ownership_basis": "historical-independent-owned-window-observation-only", + "event_ownership_proof": false, "hwnd": h.observation.hwnd, "retained_pid": h.observation.pid, + "retained_process_birth": h.observation.birth, "creator_thread": h.observation.creator_thread, + "observed_identity_epoch": h.epoch, "first_observation_sequence": h.first_sequence, + "last_observation_sequence": h.last_sequence, "first_observed_ms": h.first_ms, + "last_observed_ms": h.observation.received_ms, "evidence_event_index": h.observation.evidence_event_index, + "complete_hwnd_generation_proof": false, + })).collect(); + json!({"measurement_only": true, "changes_qualification": false, + "anonymous_categories": anonymous, "historical_owned_cache": history, + "events_with_fresh_owned_cache_observation": self.validated_events, + "decisions": self.decisions, "unresolved_decisions": self.unresolved_decisions, + "last_unresolved_count_after_recording": self.last_unresolved_after, + "counter_increment_preceded_diagnostic": self.counter_order_valid, + "per_collection_cap": CAP, "truncated": self.truncated, + "invalid_owned_observations_rejected": self.invalid_observations, + "timing_order_limit": "Modular millisecond timestamp comparison only; coarse clock-basis diagnostic, never ownership or generation proof", + "chronology_limit": "Observed sequence/identity changes only; no complete HWND generation or broker ownership claim"}) + } +} +impl Default for Diagnostics { + fn default() -> Self { + Self::new() + } +} +fn order(event: Event, history: &History) -> &'static str { + let lag = (event.received_ms as u32).wrapping_sub(event.generated_ms); + let Some(generated) = event.received_ms.checked_sub(u64::from(lag)) else { + return "unresolvable-clock-wrap"; + }; + if generated < history.first_ms { + "before-first-owned-observation" + } else if generated > history.observation.received_ms { + "after-last-owned-observation" + } else { + "within-owned-observation-span" + } +} diff --git a/crates/codexbar-providers/tests/command_runner/windows_console/hooks.rs b/crates/codexbar-providers/tests/command_runner/windows_console/hooks.rs new file mode 100644 index 00000000..e8d929fb --- /dev/null +++ b/crates/codexbar-providers/tests/command_runner/windows_console/hooks.rs @@ -0,0 +1,125 @@ +//! Current-desktop, out-of-context window events. No DLL injection or window contents. +//! The callback only queues API event tuples. The observer discards foreign tuples before persistence. +use super::*; +use std::cell::{Cell, RefCell}; +use windows::Win32::System::SystemInformation::GetTickCount64; +use windows::Win32::UI::Accessibility::{HWINEVENTHOOK, SetWinEventHook, UnhookWinEvent}; +use windows::Win32::UI::WindowsAndMessaging::{ + DispatchMessageW, EVENT_OBJECT_CREATE, EVENT_OBJECT_HIDE, MSG, OBJID_WINDOW, PM_REMOVE, PeekMessageW, + WINEVENT_OUTOFCONTEXT, +}; + +pub(super) const CAP: usize = 8192; +#[derive(Clone, Copy)] +pub(super) struct Event { + pub hwnd: HWND, + pub kind: u32, + pub thread: u32, + pub generated_ms: u32, + pub lag_ms: u32, + pub received_ms: u64, + pub sequence: u64, +} +thread_local! { + static EVENTS: RefCell> = const { RefCell::new(Vec::new()) }; + static LOST: Cell = const { Cell::new(false) }; + static SEQUENCE: Cell = const { Cell::new(0) }; +} +unsafe extern "system" fn callback( + _: HWINEVENTHOOK, + kind: u32, + hwnd: HWND, + object: i32, + child: i32, + thread: u32, + generated_ms: u32, +) { + if object != OBJID_WINDOW.0 || child != 0 || hwnd.0.is_null() { + return; + } + // No recursive observer borrow across a Windows call. Reentrancy/overflow is a measured failure. + EVENTS.with(|queue| { + let Ok(mut queue) = queue.try_borrow_mut() else { + LOST.with(|lost| lost.set(true)); + return; + }; + if queue.len() == CAP { + LOST.with(|lost| lost.set(true)); + return; + } + let received_ms = unsafe { GetTickCount64() }; + let sequence = SEQUENCE.with(|counter| { + let next = counter.get() + 1; + counter.set(next); + next + }); + queue.push(Event { + hwnd, + kind, + thread, + generated_ms, + lag_ms: (received_ms as u32).wrapping_sub(generated_ms), + received_ms, + sequence, + }); + }); +} + +pub(super) struct Hook(Option); +impl Hook { + pub fn install() -> std::result::Result { + EVENTS.with(|queue| queue.borrow_mut().clear()); + LOST.with(|lost| lost.set(false)); + SEQUENCE.with(|counter| counter.set(0)); + let hook = unsafe { + SetWinEventHook( + EVENT_OBJECT_CREATE, + EVENT_OBJECT_HIDE, + None, + Some(callback), + 0, + 0, + WINEVENT_OUTOFCONTEXT, + ) + }; + if hook.0.is_null() { + return Err(format!( + "SetWinEventHook: {}", + unsafe { windows::Win32::Foundation::GetLastError() }.0 + )); + } + Ok(Self(Some(hook))) + } + pub fn close(&mut self) -> std::result::Result<(), String> { + if let Some(hook) = self.0.take() + && !unsafe { UnhookWinEvent(hook) }.as_bool() + { + return Err(format!( + "UnhookWinEvent: {}", + unsafe { windows::Win32::Foundation::GetLastError() }.0 + )); + } + Ok(()) + } +} +impl Drop for Hook { + fn drop(&mut self) { + let _ = self.close(); + } +} + +pub(super) fn pump() -> std::result::Result, String> { + let mut message = MSG::default(); + let mut count = 0; + while unsafe { PeekMessageW(&mut message, None, 0, 0, PM_REMOVE) }.as_bool() { + count += 1; + if count > CAP { + return Err("observer message cap exceeded".into()); + } + unsafe { DispatchMessageW(&message) }; + } + if LOST.with(Cell::get) { + return Err("observer callback reentrancy/queue overflow lost events".into()); + } + Ok(EVENTS.with(|queue| std::mem::take(&mut *queue.borrow_mut()))) +} diff --git a/crates/codexbar-providers/tests/command_runner/windows_console/identity.rs b/crates/codexbar-providers/tests/command_runner/windows_console/identity.rs new file mode 100644 index 00000000..b00c0755 --- /dev/null +++ b/crates/codexbar-providers/tests/command_runner/windows_console/identity.rs @@ -0,0 +1,51 @@ +//! Read only the synthetic fixture's existing primary token. No credential store or account is contacted. +use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle}; +use windows::Win32::Foundation::HANDLE; +use windows::Win32::Security::{ + GetLengthSid, GetTokenInformation, TOKEN_QUERY, TOKEN_STATISTICS, TOKEN_USER, TokenSessionId, TokenStatistics, + TokenUser, +}; +use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + +pub(super) fn current() -> serde_json::Value { + let mut raw = HANDLE::default(); + // SAFETY: read-only access to this process's primary token; the returned handle has one RAII owner. + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut raw) }.unwrap(); + let token = unsafe { OwnedHandle::from_raw_handle(raw.0) }; + let raw = HANDLE(token.as_raw_handle()); + let mut buffer = [0usize; 256]; + let mut length = 0; + let mut statistics = TOKEN_STATISTICS::default(); + let mut session = 0u32; + // SAFETY: aligned storage larger than TOKEN_USER plus maximum SID size; exact sized outputs for other classes. + unsafe { + GetTokenInformation( + raw, + TokenUser, + Some(buffer.as_mut_ptr().cast()), + std::mem::size_of_val(&buffer) as u32, + &mut length, + ) + .unwrap(); + GetTokenInformation( + raw, + TokenStatistics, + Some(std::ptr::from_mut(&mut statistics).cast()), + std::mem::size_of_val(&statistics) as u32, + &mut length, + ) + .unwrap(); + GetTokenInformation( + raw, + TokenSessionId, + Some(std::ptr::from_mut(&mut session).cast()), + std::mem::size_of_val(&session) as u32, + &mut length, + ) + .unwrap(); + let user = &*buffer.as_ptr().cast::(); + let sid = std::slice::from_raw_parts(user.User.Sid.0.cast::(), GetLengthSid(user.User.Sid) as usize); + serde_json::json!({"user_sid": sid, "session": session, + "logon_low": statistics.AuthenticationId.LowPart, "logon_high": statistics.AuthenticationId.HighPart}) + } +} diff --git a/crates/codexbar-providers/tests/command_runner/windows_console/observer.rs b/crates/codexbar-providers/tests/command_runner/windows_console/observer.rs new file mode 100644 index 00000000..a25b3532 --- /dev/null +++ b/crates/codexbar-providers/tests/command_runner/windows_console/observer.rs @@ -0,0 +1,1031 @@ +//! Inert hosted qualification only. No vendor, account, global window contents or external broker probing. +//! A prearmed WinEvent message pump and independent enumeration cover owned fixture windows. +//! Retained process intervals qualify temporal coverage. Outside-broker ownership remains unknown. +use super::diagnostics::{Decision, Diagnostics, OwnedObservation, Reason, Stage}; +use super::*; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; +use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle}; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering}, + mpsc, +}; +use windows::Win32::Foundation::{ + FILETIME, GetHandleInformation, GetLastError, RECT, SetLastError, WAIT_OBJECT_0, WAIT_TIMEOUT, WIN32_ERROR, +}; +use windows::Win32::System::Console::{ + CONSOLE_MODE, GetConsoleMode, GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, +}; +use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS, +}; +use windows::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, SetInformationJobObject, +}; +use windows::Win32::System::StationsAndDesktops::{ + CloseDesktop, DESKTOP_CONTROL_FLAGS, DESKTOP_READOBJECTS, OpenInputDesktop, +}; +use windows::Win32::System::SystemInformation::{GetSystemTimeAsFileTime, GetTickCount64}; +use windows::Win32::System::Threading::{ + GetCurrentProcess, GetProcessIdOfThread, GetProcessTimes, OpenProcess, OpenThread, + PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, THREAD_QUERY_LIMITED_INFORMATION, WaitForSingleObject, +}; +use windows::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DestroyWindow, DispatchMessageW, EVENT_OBJECT_CREATE, EVENT_OBJECT_DESTROY, EVENT_OBJECT_HIDE, + EVENT_OBJECT_SHOW, GA_ROOT, GetAncestor, GetWindowRect, MSG, PM_REMOVE, PeekMessageW, SW_SHOWNOACTIVATE, + ShowWindow, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_POPUP, +}; +use windows::core::{PCWSTR, w}; + +#[path = "binding.rs"] +mod binding; +pub(super) fn validate_binding() { + binding::run(); +} + +pub(super) const DIRECTORY_ENV: &str = "CODEXBAR_SYNTHETIC_OBSERVER_DIRECTORY"; +const CAP: usize = 8192; +const SAMPLE_CAP: usize = 512; +const READY: Duration = Duration::from_secs(2); +const CONTROL: Duration = Duration::from_millis(800); +const POLL: Duration = Duration::from_millis(5); + +type Result = std::result::Result; +// Fixture metadata only, avoiding new Cargo features/dependencies. +#[link(name = "kernel32")] +unsafe extern "system" { + fn GetFileType(handle: HANDLE) -> u32; + fn ProcessIdToSessionId(pid: u32, session: *mut u32) -> BOOL; +} +fn session(pid: u32) -> Result { + let mut session = 0; + if !unsafe { ProcessIdToSessionId(pid, &mut session) }.as_bool() { + return Err(format!("ProcessIdToSessionId: {}", unsafe { GetLastError() }.0)); + } + Ok(session) +} +fn failure(api: &str, error: windows::core::Error) -> String { + let last_error = unsafe { GetLastError() }.0; + format!("{api}: HRESULT {}, immediate GetLastError {last_error}", error.code().0) +} +fn ticks(time: FILETIME) -> u64 { + (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime) +} +fn creation(handle: HANDLE) -> Result { + Ok(process_times(handle)?.0) +} +fn process_times(handle: HANDLE) -> Result<(u64, u64)> { + let (mut born, mut exit, mut kernel, mut user) = ( + FILETIME::default(), + FILETIME::default(), + FILETIME::default(), + FILETIME::default(), + ); + unsafe { GetProcessTimes(handle, &mut born, &mut exit, &mut kernel, &mut user) } + .map_err(|e| failure("GetProcessTimes", e))?; + Ok((ticks(born), ticks(exit))) +} +fn open(pid: u32) -> Result { + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE, false, pid) } + .map_err(|e| failure("OpenProcess owned fixture", e))?; + Ok(unsafe { OwnedHandle::from_raw_handle(handle.0) }) +} +fn name(handle: HANDLE) -> Result { + let mut buffer = [0u16; 256]; + unsafe { + GetUserObjectInformationW( + handle, + UOI_NAME, + Some(buffer.as_mut_ptr().cast()), + std::mem::size_of_val(&buffer) as u32, + None, + ) + } + .map_err(|e| failure("UOI_NAME", e))?; + let end = buffer.iter().position(|c| *c == 0).ok_or("unterminated desktop name")?; + String::from_utf16(&buffer[..end]).map_err(|e| e.to_string()) +} +fn thread_desktop(thread: u32) -> Result { + let desktop = unsafe { GetThreadDesktop(thread) }.map_err(|e| failure("GetThreadDesktop", e))?; + name(HANDLE(desktop.0)) +} +fn input_desktop() -> Result { + let desktop = unsafe { OpenInputDesktop(DESKTOP_CONTROL_FLAGS(0), false, DESKTOP_READOBJECTS) } + .map_err(|e| failure("OpenInputDesktop read-only", e))?; + let result = name(HANDLE(desktop.0)); + unsafe { CloseDesktop(desktop) }.map_err(|e| failure("CloseDesktop", e))?; + result +} +fn atomic_json(path: &Path, value: &Value) -> Result<()> { + let staging = path.with_extension("tmp"); + std::fs::write(&staging, value.to_string()).map_err(|e| e.to_string())?; + std::fs::rename(staging, path).map_err(|e| e.to_string()) +} + +pub(super) fn stdio() -> Value { + let mut rows = Vec::new(); + for (role, selector) in [ + ("stdin", STD_INPUT_HANDLE), + ("stdout", STD_OUTPUT_HANDLE), + ("stderr", STD_ERROR_HANDLE), + ] { + let row = match unsafe { GetStdHandle(selector) } { + Err(e) => json!({"role": role, "get_std_handle_error": e.code().0}), + Ok(handle) => { + unsafe { SetLastError(WIN32_ERROR(0)) }; + let file_type = unsafe { GetFileType(handle) }; + let file_type_error = if file_type == 0 { + Some(unsafe { GetLastError() }.0) + } else { + None + }; + let mut flags = 0; + let flags_error = unsafe { GetHandleInformation(handle, &mut flags) } + .err() + .map(|e| e.code().0); + let mut mode = CONSOLE_MODE::default(); + let mode_error = unsafe { GetConsoleMode(handle, &mut mode) }.err().map(|e| e.code().0); + json!({"role": role, "handle": handle.0 as usize, "handle_flags": flags, + "handle_information_error": flags_error, "console_mode": mode.0, "console_mode_error": mode_error, + "file_type": file_type, "file_type_error": file_type_error, + "expected_provider_contract": if role == "stdin" { "NUL" } else { "separate captured pipe" }}) + } + }; + rows.push(row); + } + json!(rows) +} +fn console_sample() -> Value { + // Capture last-error immediately after the failing API, before any subsequent OS call. + let mut members = [0; 16]; + let count = unsafe { GetConsoleProcessList(&mut members) } as usize; + let error = if count == 0 { + Some(unsafe { GetLastError() }.0) + } else { + None + }; + let hwnd = unsafe { GetConsoleWindow() }; + let mut owner = 0; + let tid = unsafe { GetWindowThreadProcessId(hwnd, Some(&mut owner)) }; + json!({"timestamp_100ns": ticks(unsafe { GetSystemTimeAsFileTime() }), "hwnd": hwnd.0 as usize, "console_process_list_return": count, "console_process_list_error": error, + "members": if count > 0 && count <= members.len() { Some(&members[..count]) } else { None }, + "members_truncated": count > members.len(), "owner": owner, "thread": tid, + "valid": unsafe { IsWindow(Some(hwnd)) }.as_bool(), "visible": unsafe { IsWindowVisible(hwnd) }.as_bool()}) +} + +pub(super) fn own_experiment_job() -> Result<()> { + // Only this newly invoked synthetic fixture joins this job. No existing process is assigned/killed. + let raw = + unsafe { CreateJobObjectW(None, PCWSTR::null()) }.map_err(|e| failure("experiment CreateJobObjectW", e))?; + let job = unsafe { OwnedHandle::from_raw_handle(raw.0) }; + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + unsafe { + SetInformationJobObject( + raw, + JobObjectExtendedLimitInformation, + std::ptr::from_ref(&limits).cast(), + std::mem::size_of_val(&limits) as u32, + ) + .map_err(|e| failure("experiment SetInformationJobObject", e))?; + AssignProcessToJobObject(raw, GetCurrentProcess()) + .map_err(|e| failure("experiment AssignProcessToJobObject", e))?; + } + // Deliberately process-lifetime ownership: dropping a job containing ourselves would terminate us + // before the report is flushed. Normal exit or the script's retained Popen kill closes this handle. + std::mem::forget(job); + println!( + "{}", + json!({"result": "experiment-containment", "owner_pid": std::process::id(), "kill_on_close": true}) + ); + Ok(()) +} + +pub(super) struct Client { + stop: Arc, + thread: Option>, +} +impl Drop for Client { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + } +} +pub(super) fn client_enter(depth: u32, raw: bool) -> Option { + let directory = std::env::var_os(DIRECTORY_ENV)?; + assert!(raw && depth <= 2, "qualification runs exactly one unchanged raw chain"); + let directory = PathBuf::from(directory); + let pid = std::process::id(); + let born = creation(unsafe { GetCurrentProcess() }).unwrap(); + let registration = json!({"depth": depth, "pid": pid, "creation_time": born, + "session": identity::current()["session"], "desktop": thread_desktop(unsafe { GetCurrentThreadId() }).unwrap(), + "startup": raw::startup(), "stdio": stdio(), "initial_console": console_sample(), + "creation_contract": if depth == 0 { "unchanged SystemCommandRunner production seam" } else { "raw CREATE_UNICODE_ENVIRONMENT|EXTENDED_STARTUPINFO_PRESENT 0x80400; STARTF_USESTDHANDLES 0x100" }}); + atomic_json(&directory.join(format!("client-{depth}.json")), ®istration).unwrap(); + let ack = directory.join(format!("ack-{depth}.json")); + let start = Instant::now(); + while !ack.is_file() { + assert!( + start.elapsed() < READY, + "observer did not ACK retained creation identity within 2 seconds" + ); + std::thread::sleep(POLL); + } + let value: Value = serde_json::from_slice(&std::fs::read(ack).unwrap()).unwrap(); + assert_eq!(value["pid"], pid); + assert_eq!(value["creation_time"], born); + let stop = Arc::new(AtomicBool::new(false)); + let finished = stop.clone(); + let thread = std::thread::spawn(move || { + let path = directory.join(format!("samples-{depth}.jsonl")); + let mut file = File::create(path).unwrap(); + let start = Instant::now(); + for index in 0..SAMPLE_CAP { + let row = json!({"sample": index, "elapsed_us": start.elapsed().as_micros(), "console": console_sample()}); + writeln!(file, "{row}").unwrap(); + file.flush().unwrap(); + if finished.load(Ordering::SeqCst) { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + writeln!(file, "{}", json!({"error": "console-sample-cap", "cap": SAMPLE_CAP})).unwrap(); + }); + // ACK and a live interval precede the next descendant; no provider creation flag is changed. + std::thread::sleep(Duration::from_millis(100)); + Some(Client { + stop, + thread: Some(thread), + }) +} + +struct Process { + handle: OwnedHandle, + pid: u32, + parent: u32, + born: u64, + session: u32, + depth: u32, + discovered_us: u128, + ack: bool, +} +struct Observer { + directory: PathBuf, + start: Instant, + anchor: u32, + anchor_born: u64, + session: u32, + phase: Arc, // 0 READY, 1 separate positive control, 2 provider, 3 cleanup. + control_hwnd: Arc, + stop: Arc, + processes: BTreeMap, + events: Vec, + errors: Vec, + desktop: String, + control_seen: bool, + max_gap_us: u128, + last: Instant, + scans: usize, + cap_hit: bool, + hook: Option, + coverage_start: u64, + hook_control_seen: bool, + hook_control_destroyed: bool, + unresolved_events: usize, + unresolved_api_errors: BTreeMap, + max_hook_lag_ms: u32, + known: BTreeMap, + image: String, + diagnostics: Diagnostics, +} +impl Observer { + fn retain(&mut self) -> Result<()> { + // Snapshot supplies only ancestry for filtering. Never retain/report foreign names/arguments/windows. + let raw = + unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }.map_err(|e| failure("process snapshot", e))?; + let snapshot = unsafe { OwnedHandle::from_raw_handle(raw.0) }; + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + unsafe { Process32FirstW(HANDLE(snapshot.as_raw_handle()), &mut entry) } + .map_err(|e| failure("Process32FirstW", e))?; + let mut edges = Vec::new(); + loop { + if edges.len() >= 16384 { + return Err("process snapshot entry cap exceeded".into()); + } + let end = entry + .szExeFile + .iter() + .position(|c| *c == 0) + .unwrap_or(entry.szExeFile.len()); + let same_image = String::from_utf16_lossy(&entry.szExeFile[..end]).eq_ignore_ascii_case(&self.image); + edges.push((entry.th32ProcessID, entry.th32ParentProcessID, same_image)); + if let Err(e) = unsafe { Process32NextW(HANDLE(snapshot.as_raw_handle()), &mut entry) } { + if e.code().0 != 0x80070012u32 as i32 { + return Err(failure("Process32NextW", e)); + } + break; + } + } + // Three fixed passes suffice for root/child/grandchild regardless of snapshot order. + for _ in 0..3 { + for &(pid, parent, same_image) in &edges { + if !same_image { + continue; + } // OS hosts/brokers are outside the owned fixture attribution scope. + if self.processes.contains_key(&pid) { + continue; + } + let depth = if parent == self.anchor { + Some(0) + } else { + self.processes + .get(&parent) + .filter(|p| unsafe { WaitForSingleObject(HANDLE(p.handle.as_raw_handle()), 0) } == WAIT_TIMEOUT) + .map(|p| p.depth + 1) + }; + let Some(depth) = depth else { + continue; + }; + if depth > 2 || self.processes.len() >= 3 { + return Err("unexpected owned process topology".into()); + } + let handle = open(pid)?; + let born = creation(HANDLE(handle.as_raw_handle()))?; + let session = session(pid)?; + if session != self.session { + return Err("owned process changed caller session".into()); + } + if born < self.anchor_born { + return Err("owned candidate predates retained caller creation identity".into()); + } + if let Some(p) = self.processes.get(&parent) + && born < p.born + { + return Err("retained child predates parent identity".into()); + } + self.processes.insert( + pid, + Process { + handle, + pid, + parent, + born, + session, + depth, + discovered_us: self.start.elapsed().as_micros(), + ack: false, + }, + ); + } + } + Ok(()) + } + fn acknowledge(&mut self) -> Result<()> { + for process in self.processes.values_mut() { + if process.ack { + continue; + } + let path = self.directory.join(format!("client-{}.json", process.depth)); + if !path.is_file() { + continue; + } + let row: Value = + serde_json::from_slice(&std::fs::read(path).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + if row["pid"] != process.pid + || row["creation_time"] != process.born + || row["desktop"] != self.desktop + || row["session"] != process.session + { + return Err("client registration differs from retained identity/current desktop".into()); + } + let streams = row["stdio"].as_array().ok_or("missing standard-handle metadata")?; + if streams.len() != 3 + || streams[0]["file_type"] != 2 + || streams[1]["file_type"] != 3 + || streams[2]["file_type"] != 3 + || streams[1]["handle"] == streams[2]["handle"] + || streams.iter().any(|s| !s["handle_information_error"].is_null()) + { + return Err("native provider standard handles differ from NUL/separate-pipe contract".into()); + } + if unsafe { WaitForSingleObject(HANDLE(process.handle.as_raw_handle()), 0) } != WAIT_TIMEOUT { + return Err("client exited before READY ACK".into()); + } + atomic_json( + &self.directory.join(format!("ack-{}.json", process.depth)), + &json!({"pid": process.pid, "creation_time": process.born}), + )?; + process.ack = true; + } + Ok(()) + } + fn scan(&mut self) -> Result<()> { + self.max_gap_us = self.max_gap_us.max(self.last.elapsed().as_micros()); + self.last = Instant::now(); + self.retain()?; + self.pump_events()?; + // SAFETY: synchronous enumeration borrows this live Observer exclusively. Callback never panics. + unsafe { EnumDesktopWindows(None, Some(window), LPARAM(std::ptr::from_mut(self) as isize)) } + .map_err(|e| failure("EnumDesktopWindows", e))?; + self.scans += 1; + self.acknowledge() + } + fn record(&mut self, hwnd: HWND) -> Result<()> { + let mut pid = 0; + let tid = unsafe { GetWindowThreadProcessId(hwnd, Some(&mut pid)) }; + let phase = self.phase.load(Ordering::SeqCst); + let control = phase == 1 && pid == self.anchor && hwnd.0 as usize == self.control_hwnd.load(Ordering::SeqCst); + let Some(identity) = (if control { + Some(self.anchor_born) + } else { + self.processes.get(&pid).map(|p| p.born) + }) else { + return Ok(()); + }; + if !control { + let process = &self.processes[&pid]; + // PID reuse must never associate a new owner's window with an exited retained process. + if unsafe { WaitForSingleObject(HANDLE(process.handle.as_raw_handle()), 0) } != WAIT_TIMEOUT { + return Err("window owner retained lifetime ended during enumeration".into()); + } + } + if unsafe { GetAncestor(hwnd, GA_ROOT) } != hwnd { + return Ok(()); + } + if self.events.len() >= CAP { + self.cap_hit = true; + return Err("window event cap reached".into()); + } + let mut class = [0u16; 256]; + let length = unsafe { GetClassNameW(hwnd, &mut class) }; + if length == 0 { + return Err(format!("GetClassNameW: {}", unsafe { GetLastError() }.0)); + } + let mut rect = RECT::default(); + unsafe { GetWindowRect(hwnd, &mut rect) }.map_err(|e| failure("GetWindowRect", e))?; + let desktop = thread_desktop(tid)?; + let visible = unsafe { IsWindowVisible(hwnd) }.as_bool(); + if control && visible && desktop == self.desktop { + self.control_seen = true; + } + let row = json!({"elapsed_us": self.start.elapsed().as_micros(), "timestamp_100ns": ticks(unsafe { GetSystemTimeAsFileTime() }), "source": "desktop-enumeration", "phase": if control { "positive-control" } else { "provider" }, + "hwnd": hwnd.0 as usize, "owner_pid": pid, "owner_creation_time": identity, "thread": tid, "top_level": true, + "class": String::from_utf16_lossy(&class[..length as usize]), "visible": visible, "desktop": desktop, + "rect": [rect.left, rect.top, rect.right, rect.bottom]}); + // Provenance is the independently queried window row about to be persisted. This is history, + // not evidence that a later unresolved event belongs to the same HWND generation. + self.diagnostics.observe_owned(OwnedObservation { + hwnd: hwnd.0 as u64, + pid, + birth: identity, + creator_thread: tid, + received_ms: unsafe { GetTickCount64() }, + evidence_event_index: self.events.len(), + }); + self.known.insert(hwnd.0 as usize, row.clone()); + self.events.push(row); + Ok(()) + } + fn pump_events(&mut self) -> Result<()> { + self.resolve_events(super::hooks::pump()?) + } + fn resolve_events(&mut self, events: Vec) -> Result<()> { + for event in events { + let diagnostic_event = super::diagnostics::Event { + hwnd: event.hwnd.0 as u64, + thread: event.thread, + kind: event.kind, + generated_ms: event.generated_ms, + received_ms: event.received_ms, + callback_sequence: event.sequence, + }; + let mut decision = Decision { + stage: Stage::Accepted, + reason: Reason::Accepted, + unresolved_before: self.unresolved_events, + unresolved_after: self.unresolved_events, + cached_creator_thread_match: None, + observation_sequence_before_resolution: self.diagnostics.sequence(), + independent_observation: None, + }; + let mut pid = 0; + unsafe { SetLastError(WIN32_ERROR(0)) }; + unsafe { GetWindowThreadProcessId(event.hwnd, Some(&mut pid)) }; + let window_error = if pid == 0 { unsafe { GetLastError() }.0 } else { 0 }; + if pid == 0 { + decision.stage = Stage::WindowOwner; + } + let mut thread_error = String::new(); + // A destroyed HWND can still have a live generating thread. Query only identity, no contents. + if pid == 0 { + match unsafe { OpenThread(THREAD_QUERY_LIMITED_INFORMATION, false, event.thread) } { + Ok(raw) => { + let thread = unsafe { OwnedHandle::from_raw_handle(raw.0) }; + pid = unsafe { GetProcessIdOfThread(HANDLE(thread.as_raw_handle())) }; + if pid == 0 { + thread_error = format!("GetProcessIdOfThread: {}", unsafe { GetLastError() }.0); + decision.stage = Stage::ThreadOwner; + } + } + Err(error) => { + thread_error = failure("OpenThread WinEvent identity", error); + decision.stage = Stage::ThreadOpen; + } + } + } + let previous = self.known.get(&(event.hwnd.0 as usize)).cloned(); + decision.cached_creator_thread_match = previous.as_ref().map(|row| row["thread"] == event.thread); + if pid == 0 + && let Some(row) = &previous + && row["thread"] == event.thread + { + pid = row["owner_pid"].as_u64().unwrap_or(0) as u32; + decision.stage = Stage::CacheFallback; + } + if pid == 0 { + self.unresolved_events += 1; + *self + .unresolved_api_errors + .entry(format!("GetWindowThreadProcessId: {window_error}; {thread_error}")) + .or_default() += 1; + decision.reason = Reason::Unresolved; + decision.unresolved_after = self.unresolved_events; + self.diagnostics.record(diagnostic_event, decision); // Preserve anonymous decision AFTER increment, BEFORE discard. + continue; + } // Unknown ownership cannot certify complete coverage. + if pid != self.anchor + && !self.processes.contains_key(&pid) + && let Err(error) = self.retain() + { + decision.stage = Stage::OwnedFilter; + decision.reason = Reason::QueryFailed; + self.diagnostics.record(diagnostic_event, decision); + return Err(error); + } + let owned = self.processes.contains_key(&pid); + let control = pid == self.anchor + && (event.hwnd.0 as usize == self.control_hwnd.load(Ordering::SeqCst) + || previous.as_ref().is_some_and(|row| row["phase"] == "positive-control")); + if !owned && !control { + decision.stage = Stage::OwnedFilter; + decision.reason = Reason::OutsideOwned; + self.diagnostics.record(diagnostic_event, decision); + continue; + } // Persist no foreign window tuple or metadata. + self.max_hook_lag_ms = self.max_hook_lag_ms.max(event.lag_ms); + if event.kind != EVENT_OBJECT_DESTROY && unsafe { IsWindow(Some(event.hwnd)) }.as_bool() { + if let Err(error) = self.record(event.hwnd) { + decision.stage = Stage::Metadata; + decision.reason = Reason::QueryFailed; + self.diagnostics.record(diagnostic_event, decision); + return Err(error); + } + if self.diagnostics.sequence() > decision.observation_sequence_before_resolution { + decision.independent_observation = Some(self.diagnostics.sequence()); + } + } + if unsafe { IsWindow(Some(event.hwnd)) }.as_bool() + && unsafe { GetAncestor(event.hwnd, GA_ROOT) } != event.hwnd + { + decision.stage = Stage::Metadata; + decision.reason = Reason::NotTopLevel; + self.diagnostics.record(diagnostic_event, decision); + continue; + } + let Some(mut row) = self.known.get(&(event.hwnd.0 as usize)).cloned().or(previous) else { + decision.stage = Stage::Metadata; + decision.reason = Reason::MissingMetadata; + self.diagnostics.record(diagnostic_event, decision); + return Err("owned WinEvent lacks independently queried window metadata".into()); + }; + let born = if control { + self.anchor_born + } else { + self.processes[&pid].born + }; + if row["owner_pid"] != pid || row["owner_creation_time"] != born || row["thread"] != event.thread { + decision.stage = Stage::Identity; + decision.reason = Reason::IdentityMismatch; + self.diagnostics.record(diagnostic_event, decision); + return Err("WinEvent identity differs from retained window owner/thread".into()); + } + if self.events.len() >= CAP { + self.cap_hit = true; + decision.stage = Stage::EventCap; + decision.reason = Reason::CapReached; + self.diagnostics.record(diagnostic_event, decision); + return Err("window event cap reached".into()); + } + row["source"] = json!("winevent"); + row["event"] = json!(event.kind); + row["event_tick_ms"] = json!(event.generated_ms); + row["delivery_lag_ms"] = json!(event.lag_ms); + row["elapsed_us"] = json!(self.start.elapsed().as_micros()); + if event.kind == EVENT_OBJECT_SHOW { + row["visible"] = json!(true); + } + if event.kind == EVENT_OBJECT_HIDE || event.kind == EVENT_OBJECT_DESTROY { + row["visible"] = json!(false); + } + if control && event.kind == EVENT_OBJECT_SHOW { + self.hook_control_seen = true; + } + if control && event.kind == EVENT_OBJECT_DESTROY { + self.hook_control_destroyed = true; + } + self.diagnostics.record(diagnostic_event, decision); + self.events.push(row); + } + Ok(()) + } + fn finish(mut self) -> Value { + let queue_drained = match self.pump_events() { + Ok(()) => true, + Err(error) => { + self.errors.push(error); + false + } + }; + let hook_closed = self.hook.as_mut().is_some_and(|hook| match hook.close() { + Ok(()) => true, + Err(error) => { + self.errors.push(error); + false + } + }); + let coverage_end = ticks(unsafe { GetSystemTimeAsFileTime() }); + let mut errors = self.errors; + let mut lifetimes = Vec::new(); + for p in self.processes.values() { + let status = unsafe { WaitForSingleObject(HANDLE(p.handle.as_raw_handle()), 0) }; + if status != WAIT_OBJECT_0 { + errors.push(format!( + "owned process {} not confirmed exited, wait={}", + p.pid, status.0 + )); + } + let exit_time = if status == WAIT_OBJECT_0 { + match process_times(HANDLE(p.handle.as_raw_handle())) { + Ok((_, exit)) => exit, + Err(error) => { + errors.push(error); + 0 + } + } + } else { + 0 + }; + lifetimes.push( + json!({"pid": p.pid, "parent_pid": p.parent, "creation_time": p.born, "session": p.session, "depth": p.depth, + "exit_time": exit_time, "discovered_us": p.discovered_us, "ack": p.ack, "retained_exit": status == WAIT_OBJECT_0}), + ); + } + if self.processes.len() != 3 || self.processes.values().any(|p| !p.ack) { + errors.push("missing owned root/child/grandchild registration".into()); + } + let counterexample = self.events.iter().any(provider_visible); + // A GUI control proves desktop/hook readiness only. Each independently discovered provider + // window must demonstrate its own CREATE/DESTROY delivery; do not assume console-class support. + let windows: BTreeSet<_> = self + .events + .iter() + .filter(|row| row["phase"] == "provider") + .map(|row| { + ( + row["hwnd"].as_u64().unwrap_or(0), + row["owner_creation_time"].as_u64().unwrap_or(0), + ) + }) + .collect(); + let window_lifecycle_complete = windows.iter().all(|(hwnd, born)| { + [EVENT_OBJECT_CREATE, EVENT_OBJECT_DESTROY].iter().all(|kind| { + self.events.iter().any(|row| { + row["source"] == "winevent" + && row["hwnd"] == *hwnd + && row["owner_creation_time"] == *born + && row["event"] == *kind + }) + }) + }); + let full_lifetime = self.coverage_start != 0 + && hook_closed + && queue_drained + && errors.is_empty() + && self.unresolved_events == 0 + && self.hook_control_seen + && self.hook_control_destroyed + && !self.cap_hit + && window_lifecycle_complete + && lifetimes.len() == 3 + && lifetimes.iter().all(|row| { + row["creation_time"] + .as_u64() + .is_some_and(|born| born >= self.coverage_start) + && row["exit_time"] + .as_u64() + .is_some_and(|exit| exit > 0 && exit <= coverage_end) + }); + json!({"events": self.events, "lifetimes": lifetimes, "errors": errors, "control_seen": self.control_seen, + "resolver_diagnostics": self.diagnostics.report(), + "scans": self.scans, "max_scan_gap_us": self.max_gap_us, "event_cap": CAP, "event_cap_hit": self.cap_hit, + "coverage": {"scope": "owned-fixture-top-level-windows", "method": "prearmed out-of-context WinEvent pump plus independent enumeration", + "start_100ns": self.coverage_start, "end_100ns": coverage_end, "hook_closed": hook_closed, "queue_drained": queue_drained, + "control_show_event": self.hook_control_seen, "control_destroy_event": self.hook_control_destroyed, + "provider_window_count": windows.len(), "window_lifecycle_complete": window_lifecycle_complete, + "unresolved_event_owners": self.unresolved_events, "max_owned_delivery_lag_ms": self.max_hook_lag_ms, + "unresolved_owner_api_errors": self.unresolved_api_errors, + "outside_broker": "unknown: no client association contract", "full_lifetime": full_lifetime, + "limit": "WinEvent delivery contract and measured queue drain; no arbitrary GUI/broker sandbox claim"}, + "visibility_counterexample": counterexample, "cleanup_confirmed": self.processes.len() == 3 && self.processes.values().all(|p| unsafe { WaitForSingleObject(HANDLE(p.handle.as_raw_handle()), 0) } == WAIT_OBJECT_0)}) + } +} +unsafe extern "system" fn window(hwnd: HWND, data: LPARAM) -> BOOL { + let observer = unsafe { &mut *(data.0 as *mut Observer) }; + if let Err(error) = observer.record(hwnd) + && observer.errors.len() < 16 + { + observer.errors.push(error); + // Continue enumeration to preserve bounded evidence; errors force unknown qualification. + } + true.into() +} +fn provider_visible(event: &Value) -> bool { + event["phase"] == "provider" && event["visible"] == true +} +fn protocol_checks() { + // Finite fake events verify phase separation and root inclusion before any OS experiment. + assert!(!provider_visible( + &json!({"phase": "positive-control", "visible": true}) + )); + assert!(provider_visible( + &json!({"phase": "provider", "depth": 0, "visible": true}) + )); + assert!(!provider_visible(&json!({"phase": "provider", "visible": false}))); + assert!(!provider_visible(&json!({"phase": "provider", "error": "lost-event"}))); +} + +struct Control(HWND); +impl Drop for Control { + fn drop(&mut self) { + let _ = unsafe { DestroyWindow(self.0) }; + } +} +fn control(seen: &mpsc::Receiver, expected: &AtomicUsize) -> Result { + // A predefined inert STATIC window on this detached caller's desktop, never part of the provider route. + let start = Instant::now(); + let hwnd = unsafe { + CreateWindowExW( + WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW, + w!("STATIC"), + w!(""), + WS_POPUP, + 0, + 0, + 16, + 16, + None, + None, + None, + None, + ) + } + .map_err(|e| failure("positive control CreateWindowExW", e))?; + let window = Control(hwnd); + expected.store(hwnd.0 as usize, Ordering::SeqCst); + let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) }; // Separate synthetic control only; provider flags never change. + while start.elapsed() < CONTROL { + let mut message = MSG::default(); + while unsafe { PeekMessageW(&mut message, None, 0, 0, PM_REMOVE) }.as_bool() { + unsafe { DispatchMessageW(&message) }; + } + if seen.try_recv() == Ok(true) { + unsafe { DestroyWindow(window.0) }.map_err(|e| failure("positive control DestroyWindow", e))?; + expected.store(0, Ordering::SeqCst); + if unsafe { IsWindow(Some(window.0)) }.as_bool() { + return Err("positive control window cleanup unconfirmed".into()); + } + let report = json!({"hwnd": window.0.0 as usize, "elapsed_us": start.elapsed().as_micros(), + "console_hwnd": unsafe { GetConsoleWindow() }.0 as usize, + "independently_seen": true, "destroyed": true, "is_original_red": false}); + std::mem::forget(window); + if start.elapsed() >= Duration::from_secs(1) { + return Err("positive control exceeded 1s limit".into()); + } + return Ok(report); + } + std::thread::sleep(POLL); + } + Err("positive control was not independently discovered within 800ms".into()) +} + +pub(super) fn experiment() { + protocol_checks(); + let directory = PathBuf::from(std::env::var_os(DIRECTORY_ENV).expect("qualification protocol directory")); + let evidence_directory = directory.clone(); + let start = Instant::now(); + let phase = Arc::new(AtomicU8::new(0)); + let control_hwnd = Arc::new(AtomicUsize::new(0)); + let stop = Arc::new(AtomicBool::new(false)); + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let (seen_tx, seen_rx) = mpsc::sync_channel(1); + let observer_phase = phase.clone(); + let expected_control = control_hwnd.clone(); + let observer_stop = stop.clone(); + let observer = std::thread::spawn(move || { + let desktop = thread_desktop(unsafe { GetCurrentThreadId() }); + let input = input_desktop(); + let anchor_born = creation(unsafe { GetCurrentProcess() }); + let caller_session = session(std::process::id()); + let ready = match (&desktop, &input, &anchor_born, &caller_session) { + (Ok(current), Ok(input), Ok(born), Ok(session)) + if current == input && station::current()["visible"] == true && *session != 0 => + { + Ok( + json!({"desktop": current, "input_desktop": input, "caller_pid": std::process::id(), "caller_creation_time": born, "session": session, "station": station::current()}), + ) + } + _ => Err(format!( + "interactive/current-input desktop/identity qualification failed: {desktop:?}/{input:?}/{anchor_born:?}/{caller_session:?}" + )), + }; + let mut state = Observer { + directory, + start, + anchor: std::process::id(), + anchor_born: anchor_born.unwrap_or(0), // Unavailable identity already rejects READY above. + session: caller_session.unwrap_or(0), + phase: observer_phase, + control_hwnd: expected_control, + stop: observer_stop, + processes: BTreeMap::new(), + events: Vec::new(), + errors: Vec::new(), + desktop: desktop.unwrap_or_default(), + control_seen: false, + max_gap_us: 0, + last: Instant::now(), + scans: 0, + cap_hit: false, + hook: None, + coverage_start: 0, + hook_control_seen: false, + hook_control_destroyed: false, + unresolved_events: 0, + unresolved_api_errors: BTreeMap::new(), + max_hook_lag_ms: 0, + known: BTreeMap::new(), + image: std::env::current_exe() + .unwrap() + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(), + diagnostics: Diagnostics::new(), + }; + // READY follows hook installation and a successful pump/scan; providers/control have not started. + let ready = ready.and_then(|value| { + state.hook = Some(super::hooks::Hook::install()?); + state.coverage_start = ticks(unsafe { GetSystemTimeAsFileTime() }); + state.scan()?; + Ok(value) + }); + let ready_ok = ready.is_ok(); + if ready_tx.send(ready).is_err() { + state.errors.push("READY receiver exited".into()); + } + if !ready_ok { + return state.finish(); + } + let mut notified = false; + while start.elapsed() < Duration::from_secs(15) { + if let Err(error) = state.scan() { + state.errors.push(error); + break; + } + if state.control_seen && state.hook_control_seen && !notified { + let _ = seen_tx.try_send(true); + notified = true; + } + if state.stop.load(Ordering::SeqCst) { + // One final independent desktop scan after all retained clients exited. + if let Err(error) = state.scan() { + state.errors.push(error); + } + return state.finish(); + } + std::thread::sleep(POLL); + } + state + .errors + .push("observer exited early or exceeded finite 15s lifetime".into()); + state.finish() + }); + let mut report = json!({"result": "visibility-observer", "route": "raw-handle-list", "ready": false, + "classification": "unqualified", "visibility_counterexample": false, "shared_hidden_console": false, + "full_observation": false, "runner_limit_ms": 10000, "ready_limit_ms": 2000, "control_limit_ms": 800, + "positive_control_is_red": false, "failure": null, + "control": {"status": "not started", "cleanup": "unknown"}, + "default_failure_state": {"event_loss": "unknown", "coverage": "unknown", "cleanup": "unknown"}}); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<()> { + let ready = ready_rx.recv_timeout(READY).map_err(|e| format!("READY ACK: {e}"))??; + report["ready"] = json!(true); + report["ready_ack_us"] = json!(start.elapsed().as_micros()); + report["interactive_station"] = json!(true); + report["ready_context"] = ready; + phase.store(1, Ordering::SeqCst); + report["control"] = control(&seen_rx, &control_hwnd)?; + phase.store(2, Ordering::SeqCst); + let executable = std::env::current_exe().map_err(|e| e.to_string())?; + let output = SystemCommandRunner + .run( + executable.to_str().ok_or("non-Unicode fixture path")?, + &["window-raw-tree", "0"], + Duration::from_secs(10), + ) + .map_err(|e| format!("runner: {e:?}"))?; + if !output.success { + return Err(format!("native tree failed: {}", output.stderr)); + } + report["rows"] = serde_json::from_str(&format!("[{}]", output.stdout.lines().collect::>().join(","))) + .map_err(|e| e.to_string())?; + phase.store(3, Ordering::SeqCst); + // Retained process handles, not PID absence, qualify cleanup. Allow finite asynchronous job teardown. + std::thread::sleep(Duration::from_millis(100)); + Ok(()) + })); + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => report["failure"] = json!(error), + Err(_) => report["failure"] = json!("observer experiment panicked; no qualification"), + } + stop.store(true, Ordering::SeqCst); + match observer.join() { + Ok(observation) => { + report["visibility_counterexample"] = observation["visibility_counterexample"].clone(); + report["full_observation"] = observation["coverage"]["full_lifetime"].clone(); + report["observation"] = observation; + } + Err(_) => report["failure"] = json!("independent observer panicked/early exit"), + } + report["elapsed_us"] = json!(start.elapsed().as_micros()); + // Preserve bounded protocol/sampler evidence before the parent's Scratch directory is removed. + let mut protocol = Vec::new(); + let mut protocol_errors = Vec::new(); + for depth in 0..3 { + let registration = std::fs::read(evidence_directory.join(format!("client-{depth}.json"))) + .map_err(|e| e.to_string()) + .and_then(|bytes| serde_json::from_slice::(&bytes).map_err(|e| e.to_string())); + let samples = std::fs::read_to_string(evidence_directory.join(format!("samples-{depth}.jsonl"))) + .map_err(|e| e.to_string()) + .and_then(|text| { + if text.lines().count() > SAMPLE_CAP + 1 { + return Err("console sample cap exceeded".into()); + } + text.lines() + .map(|line| serde_json::from_str::(line).map_err(|e| e.to_string())) + .collect::>>() + }); + if registration.is_err() || samples.is_err() { + protocol_errors.push(format!( + "depth {depth}: registration={:?}, samples={:?}", + registration.as_ref().err(), + samples.as_ref().err() + )); + } + if let Ok(samples) = &samples + && (samples.is_empty() + || samples + .iter() + .any(|s| !s["error"].is_null() || s["console"]["members"].is_null())) + { + protocol_errors.push(format!("depth {depth}: missing, capped or failed console samples")); + } + protocol.push(json!({"depth": depth, "registration": registration.ok(), "samples": samples.ok()})); + } + report["client_protocol"] = json!(protocol); + report["protocol_errors"] = json!(protocol_errors); + // Unknown pre-discovery/broker coverage is never promoted to PASS, even if candidate association is hidden. + if let Some(rows) = report["rows"].as_array() { + let hidden = rows.len() == 3 + && rows.iter().all(|r| { + r["valid"] == true + && r["enumerated"] == true + && r["visible"] == false + && r["class"] == "ConsoleWindowClass" + && r["hwnd"] != 0 + && r["hwnd"] == rows[0]["hwnd"] + && r["owner"] != 0 + && r["owner"] == rows[0]["owner"] + && r["desktop"] == r["window_desktop"] + }); + report["shared_hidden_console"] = json!(hidden); + } + println!("{report}"); +} diff --git a/crates/codexbar-providers/tests/command_runner/windows_console/raw.rs b/crates/codexbar-providers/tests/command_runner/windows_console/raw.rs new file mode 100644 index 00000000..e1f21743 --- /dev/null +++ b/crates/codexbar-providers/tests/command_runner/windows_console/raw.rs @@ -0,0 +1,106 @@ +//! Ordinary Win32 creation with the observed Go startup/creation flags, not a request for a new console. +use std::os::windows::ffi::OsStrExt as _; +use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle}; +use windows::Win32::Foundation::{HANDLE, WAIT_OBJECT_0}; +use windows::Win32::System::Console::{GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE}; +use windows::Win32::System::Threading::{ + CREATE_UNICODE_ENVIRONMENT, CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, + GetExitCodeProcess, GetStartupInfoW, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOEXW, STARTUPINFOW, + TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, +}; +use windows::core::{PCWSTR, PWSTR}; + +struct Attributes(Vec); +impl Attributes { + fn pointer(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + LPPROC_THREAD_ATTRIBUTE_LIST(self.0.as_mut_ptr().cast()) + } +} +impl Drop for Attributes { + fn drop(&mut self) { + unsafe { DeleteProcThreadAttributeList(self.pointer()) }; + } +} + +pub(super) fn startup() -> serde_json::Value { + let mut info = STARTUPINFOW::default(); + unsafe { GetStartupInfoW(&mut info) }; + serde_json::json!({"flags": info.dwFlags.0, "show_window": info.wShowWindow}) +} + +pub(super) fn child(depth: u32) { + let program = std::env::current_exe().unwrap(); + let application: Vec = program.as_os_str().encode_wide().chain([0]).collect(); + let mut line: Vec = format!("\"{}\" window-raw-tree {depth}", program.display()) + .encode_utf16() + .chain([0]) + .collect(); + // Only existing standard handles are selected. No visibility hint or console-creation override is supplied. + let handles = unsafe { + [ + GetStdHandle(STD_INPUT_HANDLE).unwrap(), + GetStdHandle(STD_OUTPUT_HANDLE).unwrap(), + GetStdHandle(STD_ERROR_HANDLE).unwrap(), + ] + }; + let mut size = 0; + let _ = unsafe { InitializeProcThreadAttributeList(None, 1, None, &mut size) }; + assert!(size > 0); + let mut storage = vec![0; size.div_ceil(std::mem::size_of::())]; + unsafe { + InitializeProcThreadAttributeList( + Some(LPPROC_THREAD_ATTRIBUTE_LIST(storage.as_mut_ptr().cast())), + 1, + None, + &mut size, + ) + } + .unwrap(); + let mut attributes = Attributes(storage); + unsafe { + UpdateProcThreadAttribute( + attributes.pointer(), + 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, + Some(handles.as_ptr().cast()), + std::mem::size_of_val(&handles), + None, + None, + ) + } + .unwrap(); + let mut startup = STARTUPINFOEXW::default(); + startup.StartupInfo.cb = std::mem::size_of_val(&startup) as u32; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; // 0x100 + startup.StartupInfo.hStdInput = handles[0]; + startup.StartupInfo.hStdOutput = handles[1]; + startup.StartupInfo.hStdError = handles[2]; + startup.lpAttributeList = attributes.pointer(); + let mut info = PROCESS_INFORMATION::default(); + // SAFETY: owned UTF-16 buffers, initialized attributes, live inherited standard handles and writable outputs. + unsafe { + CreateProcessW( + PCWSTR(application.as_ptr()), + Some(PWSTR(line.as_mut_ptr())), + None, + None, + true, + CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT, + None, + PCWSTR::null(), + &startup.StartupInfo, + &mut info, + ) + } + .unwrap(); // 0x80400 + let process = unsafe { OwnedHandle::from_raw_handle(info.hProcess.0) }; + let _thread = unsafe { OwnedHandle::from_raw_handle(info.hThread.0) }; + if unsafe { WaitForSingleObject(HANDLE(process.as_raw_handle()), 10_000) } != WAIT_OBJECT_0 { + let _ = unsafe { TerminateProcess(HANDLE(process.as_raw_handle()), 1) }; + panic!("raw ordinary child did not finish"); + } + let mut code = 0; + unsafe { GetExitCodeProcess(HANDLE(process.as_raw_handle()), &mut code) }.unwrap(); + assert_eq!(code, 0); +} diff --git a/crates/codexbar-providers/tests/command_runner/windows_console/station.rs b/crates/codexbar-providers/tests/command_runner/windows_console/station.rs new file mode 100644 index 00000000..60ac8bb2 --- /dev/null +++ b/crates/codexbar-providers/tests/command_runner/windows_console/station.rs @@ -0,0 +1,31 @@ +use windows::Win32::Foundation::HANDLE; +use windows::Win32::System::StationsAndDesktops::{ + GetProcessWindowStation, GetUserObjectInformationW, UOI_FLAGS, UOI_NAME, USEROBJECTFLAGS, +}; + +pub(super) fn current() -> serde_json::Value { + let mut name = [0u16; 256]; + let mut flags = USEROBJECTFLAGS::default(); + // SAFETY: read-only queries of our existing window station, with sized writable output buffers. + unsafe { + let station = GetProcessWindowStation().unwrap(); + GetUserObjectInformationW( + HANDLE(station.0), + UOI_NAME, + Some(name.as_mut_ptr().cast()), + std::mem::size_of_val(&name) as u32, + None, + ) + .unwrap(); + GetUserObjectInformationW( + HANDLE(station.0), + UOI_FLAGS, + Some(std::ptr::from_mut(&mut flags).cast()), + std::mem::size_of_val(&flags) as u32, + None, + ) + .unwrap(); + } + serde_json::json!({"name": String::from_utf16(&name[..name.iter().position(|ch| *ch == 0).unwrap()]).unwrap(), + "flags": flags.dwFlags, "visible": flags.dwFlags & 1 != 0}) // WSF_VISIBLE +} diff --git a/crates/codexbar-providers/tests/command_runner/windows_sentinel.rs b/crates/codexbar-providers/tests/command_runner/windows_sentinel.rs new file mode 100644 index 00000000..602ddc66 --- /dev/null +++ b/crates/codexbar-providers/tests/command_runner/windows_sentinel.rs @@ -0,0 +1,74 @@ +//! An unrelated inheritable writer must not enter a live production-launched provider. +use super::*; +use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle}; +use windows::Win32::Foundation::{ERROR_BROKEN_PIPE, HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation, WAIT_TIMEOUT}; +use windows::Win32::System::Pipes::{CreatePipe, PeekNamedPipe}; +use windows::Win32::System::Threading::{OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject}; + +pub(super) fn qualify() { + let mut read = HANDLE::default(); + let mut write = HANDLE::default(); + // SAFETY: writable handle outputs; neither endpoint initially inheritable. + unsafe { CreatePipe(&mut read, &mut write, None, 0) }.unwrap(); + let read = unsafe { File::from_raw_handle(read.0) }; + let write = unsafe { File::from_raw_handle(write.0) }; + unsafe { + SetHandleInformation( + HANDLE(write.as_raw_handle()), + HANDLE_FLAG_INHERIT.0, + HANDLE_FLAG_INHERIT, + ) + } + .unwrap(); + let scratch = Scratch::new(); + let marker = scratch.marker("unrelated-pipe-sentinel"); + std::thread::scope(|scope| { + let marker = ▮ + let running = scope.spawn(move || { + let executable = std::env::current_exe().unwrap(); + SystemCommandRunner.run( + executable.to_str().unwrap(), + &["hang", marker.to_str().unwrap()], + Duration::from_secs(3), + ) + }); + let started = Instant::now(); + while !marker.with_extension("ready").exists() { + assert!(started.elapsed() < Duration::from_secs(2)); + std::thread::sleep(Duration::from_millis(5)); + } + // The provider is alive and holding its lock. Close the sole intended writer while it remains alive. + let pid = std::fs::read_to_string(marker.with_extension("ready")) + .unwrap() + .parse() + .unwrap(); + let process = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, false, pid) }.unwrap(); + let process = unsafe { OwnedHandle::from_raw_handle(process.0) }; + assert_eq!( + unsafe { WaitForSingleObject(HANDLE(process.as_raw_handle()), 0) }, + WAIT_TIMEOUT + ); + let held = File::open(marker).unwrap(); + assert!(matches!(held.try_lock(), Err(std::fs::TryLockError::WouldBlock))); + drop(write); + // If the unrelated writer leaked into that live provider, this returns Ok with no bytes instead of EOF. + let error = unsafe { PeekNamedPipe(HANDLE(read.as_raw_handle()), None, 0, None, None, None) }.unwrap_err(); + assert_eq!( + error.code(), + ERROR_BROKEN_PIPE.to_hresult(), + "unrelated inheritable writer entered live provider" + ); + assert_eq!( + unsafe { WaitForSingleObject(HANDLE(process.as_raw_handle()), 0) }, + WAIT_TIMEOUT, + "sentinel EOF must be verified while the retained provider process is alive" + ); + assert!( + matches!(held.try_lock(), Err(std::fs::TryLockError::WouldBlock)), + "EOF must be proved before provider exit" + ); + assert_eq!(running.join().unwrap(), Err(CommandError::TimedOut)); + }); + released(&marker); + println!("unrelated inheritable pipe reached EOF while provider was still alive"); +} diff --git a/crates/codexbar-providers/tests/window_binding.rs b/crates/codexbar-providers/tests/window_binding.rs new file mode 100644 index 00000000..c9853109 --- /dev/null +++ b/crates/codexbar-providers/tests/window_binding.rs @@ -0,0 +1,166 @@ +//! Synthetic protocol decisions only. These tests make no claim about actual Windows event tuples. +#[path = "command_runner/windows_console/binding_policy.rs"] +mod binding_policy; +use binding_policy::{Actor, Binding, Registration, Ticket, Witness}; + +fn registration() -> Registration { + Registration { + hwnd: 42, + nonce: 7, + generation: 1, + creator: Actor { + pid: 100, + process_birth: 200, + tid: 300, + thread_birth: 400, + }, + generator: Actor { + pid: 100, + process_birth: 200, + tid: 301, + thread_birth: 401, + }, + } +} +fn ticket() -> Ticket { + Ticket { + nonce: 7, + generation: 1, + command: 1, + kind: 0x8002, + } +} +fn witness() -> Witness { + Witness { + hwnd: 42, + kind: 0x8002, + callback_sequence: 8, + generator: registration().generator, + live_creator: Some(registration().creator), + } +} + +#[test] +fn registered_separate_generator_maps_live_then_explicitly_handed_off_destroy() { + let r = registration(); + let mut b = Binding::register(r, r.creator).unwrap(); + b.map(ticket(), witness()).unwrap(); + b.confirm_destroyed(r.nonce, r.generation).unwrap(); + b.map( + Ticket { + command: 2, + kind: 0x8001, + ..ticket() + }, + Witness { + kind: 0x8001, + callback_sequence: 9, + generator: r.creator, + live_creator: None, + ..witness() + }, + ) + .unwrap(); +} + +#[test] +fn stale_generation_unknown_hwnd_and_replayed_events_fail_closed() { + let r = registration(); + for t in [ + Ticket { nonce: 8, ..ticket() }, + Ticket { + generation: 2, + ..ticket() + }, + Ticket { command: 3, ..ticket() }, + ] { + assert!(Binding::register(r, r.creator).unwrap().map(t, witness()).is_err()); + } + assert!( + Binding::register(r, r.creator) + .unwrap() + .map(ticket(), Witness { hwnd: 99, ..witness() }) + .is_err() + ); + let mut b = Binding::register(r, r.creator).unwrap(); + b.map(ticket(), witness()).unwrap(); + assert!(b.map(ticket(), witness()).is_err()); +} + +#[test] +fn recycled_numeric_hwnd_does_not_transfer_the_cooperative_generation() { + let r = Registration { + generation: 2, + ..registration() + }; + assert!( + Binding::register(r, r.creator) + .unwrap() + .map(ticket(), witness()) + .is_err() + ); +} + +#[test] +fn wrong_thread_process_birth_or_thread_birth_never_attributes() { + let r = registration(); + for generator in [ + Actor { + tid: r.creator.tid, + ..r.generator + }, + Actor { + thread_birth: 999, + ..r.generator + }, + Actor { + process_birth: 999, + ..r.generator + }, + Actor { + pid: 999, + ..r.generator + }, + ] { + assert!( + Binding::register(r, r.creator) + .unwrap() + .map(ticket(), Witness { generator, ..witness() }) + .is_err() + ); + } + assert!(Binding::register(r, r.generator).is_err()); +} + +#[test] +fn invalid_destroy_handoff_and_missing_live_metadata_stay_unknown() { + let r = registration(); + assert!( + Binding::register(r, r.creator) + .unwrap() + .map( + ticket(), + Witness { + live_creator: None, + ..witness() + } + ) + .is_err() + ); + let mut b = Binding::register(r, r.creator).unwrap(); + assert!(b.confirm_destroyed(r.nonce + 1, r.generation).is_err()); + assert!( + b.map( + Ticket { + kind: 0x8001, + ..ticket() + }, + Witness { + kind: 0x8001, + live_creator: None, + ..witness() + } + ) + .is_err() + ); +} diff --git a/crates/codexbar-providers/tests/window_diagnostics.rs b/crates/codexbar-providers/tests/window_diagnostics.rs new file mode 100644 index 00000000..dfa034d5 --- /dev/null +++ b/crates/codexbar-providers/tests/window_diagnostics.rs @@ -0,0 +1,422 @@ +//! Portable privacy/measurement policy tests. No native process, console, GUI or provider is launched. +#[path = "command_runner/windows_console/diagnostics.rs"] +mod diagnostics; +use diagnostics::{Decision, Diagnostics, Event, OwnedObservation, Reason, Stage}; + +fn event() -> Event { + Event { + hwnd: 918273645, + thread: 81726354, + kind: 0x8001, + generated_ms: 160, + received_ms: 180, + callback_sequence: 7, + } +} +fn observation() -> OwnedObservation { + OwnedObservation { + hwnd: event().hwnd, + pid: 7654321, + birth: 12345678901234, + creator_thread: event().thread, + received_ms: 150, + evidence_event_index: 11, + } +} +fn unresolved(before: usize) -> Decision { + Decision { + stage: Stage::ThreadOpen, + reason: Reason::Unresolved, + unresolved_before: before, + unresolved_after: before + 1, + cached_creator_thread_match: None, + observation_sequence_before_resolution: 0, + independent_observation: None, + } +} + +#[test] +fn cache_misses_are_bounded_anonymous_aggregates_before_discard() { + let mut diagnostics = Diagnostics::new(); + for index in 0..10_000 { + diagnostics.record( + Event { + hwnd: event().hwnd + index as u64, + thread: event().thread + index as u32, + ..event() + }, + unresolved(index), + ); + } + let report = diagnostics.report(); + assert_eq!(report["anonymous_categories"].as_array().unwrap().len(), 1); + assert_eq!(report["anonymous_categories"][0]["count"], 10_000); + assert_eq!(report["unresolved_decisions"], 10_000); + assert_eq!(report["last_unresolved_count_after_recording"], 10_000); + assert_eq!(report["counter_increment_preceded_diagnostic"], true); + assert_eq!(report["historical_owned_cache"], serde_json::json!([])); + assert_eq!( + report["events_with_fresh_owned_cache_observation"], + serde_json::json!([]) + ); + let text = report.to_string(); + for private in [ + "918273645", + "81726354", + "7654321", + "12345678901234", + "callback_sequence", + "event_generated_ms", + ] { + assert!( + !text.contains(private), + "unmatched raw field escaped redaction: {private}" + ); + } +} + +#[test] +fn historical_owned_cache_does_not_attribute_unresolved_or_mismatched_events() { + let mut diagnostics = Diagnostics::new(); + diagnostics.observe_owned(observation()); + diagnostics.record( + Event { + thread: event().thread + 1, + ..event() + }, + Decision { + cached_creator_thread_match: Some(false), + ..unresolved(0) + }, + ); + let report = diagnostics.report(); + let category = &report["anonymous_categories"][0]; + assert_eq!(category["cached_creator_thread_match"], false); + assert_eq!(category["event_kind"], "destroy"); + assert_eq!(category["failure_stage"], "ThreadOpen"); + assert_eq!( + category["generation_time_order_vs_owned_observations"], + "after-last-owned-observation" + ); + assert!(category.get("hwnd").is_none()); + assert!(category.get("event_thread").is_none()); + assert_eq!( + report["events_with_fresh_owned_cache_observation"], + serde_json::json!([]) + ); + let historical = &report["historical_owned_cache"][0]; + assert_eq!(historical["event_ownership_proof"], false); + assert_eq!(historical["complete_hwnd_generation_proof"], false); + assert_eq!(historical["evidence_event_index"], 11); + assert!(!report.to_string().contains(&(event().thread + 1).to_string())); +} + +#[test] +fn only_fresh_independent_owned_observation_allows_raw_event_evidence() { + let mut diagnostics = Diagnostics::new(); + diagnostics.observe_owned(observation()); + let floor = diagnostics.sequence(); + diagnostics.observe_owned(observation()); + diagnostics.record( + event(), + Decision { + stage: Stage::Accepted, + reason: Reason::Accepted, + unresolved_before: 0, + unresolved_after: 0, + cached_creator_thread_match: Some(true), + observation_sequence_before_resolution: floor, + independent_observation: Some(diagnostics.sequence()), + }, + ); + let report = diagnostics.report(); + let raw = &report["events_with_fresh_owned_cache_observation"][0]; + assert_eq!(raw["event_ownership_proof"], false); + assert_eq!(raw["hwnd"], event().hwnd); + assert_eq!(raw["retained_process_birth"], observation().birth); + assert_eq!(raw["callback_sequence"], event().callback_sequence); + assert_eq!(raw["evidence_event_index"], 11); + assert_eq!(raw["complete_hwnd_generation_proof"], false); + assert_eq!(report["changes_qualification"], false); + let floor = diagnostics.sequence(); + diagnostics.observe_owned(observation()); + diagnostics.record( + event(), + Decision { + stage: Stage::Accepted, + reason: Reason::Accepted, + unresolved_before: 0, + unresolved_after: 0, + cached_creator_thread_match: None, + observation_sequence_before_resolution: floor, + independent_observation: Some(diagnostics.sequence()), + }, + ); + assert_eq!( + diagnostics.report()["events_with_fresh_owned_cache_observation"] + .as_array() + .unwrap() + .len(), + 1, + "a cache-miss decision stays anonymous even when a new owned observation was made later" + ); +} + +#[test] +fn stale_token_and_reused_window_thread_or_process_identity_cannot_export_raw_tuple() { + let mut diagnostics = Diagnostics::new(); + diagnostics.observe_owned(observation()); + let old = diagnostics.sequence(); + diagnostics.record( + event(), + Decision { + stage: Stage::CacheFallback, + reason: Reason::Accepted, + unresolved_before: 0, + unresolved_after: 0, + cached_creator_thread_match: Some(true), + observation_sequence_before_resolution: old, + independent_observation: Some(old), + }, + ); + diagnostics.observe_owned(OwnedObservation { + creator_thread: event().thread + 1, + birth: observation().birth + 1, + ..observation() + }); + diagnostics.record( + event(), + Decision { + stage: Stage::Identity, + reason: Reason::IdentityMismatch, + unresolved_before: 0, + unresolved_after: 0, + cached_creator_thread_match: Some(false), + observation_sequence_before_resolution: old, + independent_observation: Some(old), + }, + ); + diagnostics.record( + event(), + Decision { + stage: Stage::Accepted, + reason: Reason::Accepted, + unresolved_before: 0, + unresolved_after: 0, + cached_creator_thread_match: Some(true), + observation_sequence_before_resolution: old, + independent_observation: Some(diagnostics.sequence()), + }, + ); + let report = diagnostics.report(); + assert_eq!( + report["events_with_fresh_owned_cache_observation"], + serde_json::json!([]) + ); + assert_eq!(report["historical_owned_cache"][0]["observed_identity_epoch"], 2); + assert_eq!( + report["historical_owned_cache"][0]["complete_hwnd_generation_proof"], + false + ); +} + +#[test] +fn delayed_destroy_and_orphan_remain_anonymous_without_new_validation() { + let mut diagnostics = Diagnostics::new(); + diagnostics.observe_owned(observation()); + diagnostics.record( + Event { + generated_ms: 140, + received_ms: 5000, + ..event() + }, + Decision { + cached_creator_thread_match: Some(true), + ..unresolved(0) + }, + ); + diagnostics.record( + Event { + hwnd: 99887766, + ..event() + }, + unresolved(1), + ); + let report = diagnostics.report(); + assert_eq!(report["unresolved_decisions"], 2); + assert_eq!(report["counter_increment_preceded_diagnostic"], true); + assert_eq!( + report["events_with_fresh_owned_cache_observation"], + serde_json::json!([]) + ); + assert!( + report["anonymous_categories"] + .as_array() + .unwrap() + .iter() + .any(|row| row["generation_time_order_vs_owned_observations"] == "before-first-owned-observation") + ); + assert!(!report.to_string().contains("99887766")); +} + +#[test] +fn diagnostic_counter_detects_recording_before_original_unresolved_increment() { + let mut diagnostics = Diagnostics::new(); + diagnostics.record( + event(), + Decision { + unresolved_after: 0, + ..unresolved(0) + }, + ); + assert_eq!(diagnostics.report()["counter_increment_preceded_diagnostic"], false); +} + +#[test] +fn raw_history_and_category_collections_have_finite_caps() { + let mut diagnostics = Diagnostics::new(); + for index in 0..200 { + let observation = OwnedObservation { + hwnd: event().hwnd + index, + ..observation() + }; + diagnostics.observe_owned(observation); + let floor = diagnostics.sequence(); + diagnostics.observe_owned(observation); + diagnostics.record( + Event { + hwnd: observation.hwnd, + ..event() + }, + Decision { + stage: Stage::Accepted, + reason: Reason::Accepted, + unresolved_before: 0, + unresolved_after: 0, + cached_creator_thread_match: Some(true), + observation_sequence_before_resolution: floor, + independent_observation: Some(diagnostics.sequence()), + }, + ); + } + for kind in [0x8000, 0x8001, 0x8002, 0x8003, 0xffff] { + for stage in [Stage::WindowOwner, Stage::OwnedFilter, Stage::Metadata] { + for reason in [ + Reason::OutsideOwned, + Reason::NotTopLevel, + Reason::QueryFailed, + Reason::MissingMetadata, + Reason::IdentityMismatch, + ] { + diagnostics.record( + Event { + hwnd: 99887766, + kind, + ..event() + }, + Decision { + stage, + reason, + unresolved_before: 0, + unresolved_after: 0, + cached_creator_thread_match: None, + observation_sequence_before_resolution: diagnostics.sequence(), + independent_observation: None, + }, + ); + } + } + } + let report = diagnostics.report(); + assert_eq!(report["historical_owned_cache"].as_array().unwrap().len(), 64); + assert_eq!( + report["events_with_fresh_owned_cache_observation"] + .as_array() + .unwrap() + .len(), + 64 + ); + assert_eq!(report["anonymous_categories"].as_array().unwrap().len(), 64); + assert_eq!(report["truncated"], true); + assert_eq!(report["changes_qualification"], false); +} + +#[test] +fn tick_wrap_and_observation_epochs_are_chronology_only() { + let mut diagnostics = Diagnostics::new(); + let wrapped = (1u64 << 32) + 20; + diagnostics.observe_owned(OwnedObservation { + received_ms: wrapped, + ..observation() + }); + diagnostics.observe_owned(OwnedObservation { + received_ms: wrapped + 10, + ..observation() + }); + diagnostics.record( + Event { + generated_ms: 10, + received_ms: wrapped + 20, + ..event() + }, + Decision { + cached_creator_thread_match: Some(true), + ..unresolved(0) + }, + ); + let report = diagnostics.report(); + assert_eq!( + report["anonymous_categories"][0]["generation_time_order_vs_owned_observations"], + "before-first-owned-observation" + ); + let history = &report["historical_owned_cache"][0]; + assert_eq!(history["first_observation_sequence"], 1); + assert_eq!(history["last_observation_sequence"], 2); + assert_eq!(history["observed_identity_epoch"], 1); + assert_eq!(history["complete_hwnd_generation_proof"], false); + assert_eq!(history["event_ownership_proof"], false); +} + +#[test] +fn invalid_owned_input_is_redacted_and_all_failure_stages_are_categorical() { + let mut diagnostics = Diagnostics::new(); + diagnostics.observe_owned(OwnedObservation { + birth: 0, + ..observation() + }); + for stage in [ + Stage::WindowOwner, + Stage::ThreadOwner, + Stage::OwnedFilter, + Stage::Metadata, + Stage::EventCap, + ] { + for reason in [ + Reason::OutsideOwned, + Reason::NotTopLevel, + Reason::QueryFailed, + Reason::MissingMetadata, + Reason::CapReached, + ] { + diagnostics.record( + event(), + Decision { + stage, + reason, + unresolved_before: 0, + unresolved_after: 0, + cached_creator_thread_match: None, + observation_sequence_before_resolution: 0, + independent_observation: None, + }, + ); + } + } + let report = diagnostics.report(); + assert_eq!(report["invalid_owned_observations_rejected"], 1); + assert_eq!(report["historical_owned_cache"], serde_json::json!([])); + assert_eq!( + report["events_with_fresh_owned_cache_observation"], + serde_json::json!([]) + ); +} diff --git a/docs/COMMAND_RUNNER.md b/docs/COMMAND_RUNNER.md index b97a9262..9b4b2397 100644 --- a/docs/COMMAND_RUNNER.md +++ b/docs/COMMAND_RUNNER.md @@ -1,8 +1,61 @@ # Provider command collection -`SystemCommandRunner` runs usage-query CLIs through the existing platform -program lookup. It does not invoke a shell, opens no console window, and closes -stdin. Interactive sign-in flows use their separate login adapters. +`SystemCommandRunner` runs usage-query CLIs with null stdin and separate capture +pipes. Native executables run directly. Windows batch shims use the system +`cmd.exe`, as Rust's `Command` does. Interactive sign-in flows use their separate +login adapters. + +## Windows ordinary descendants + +The proposed Windows usage runner requests a new console with +`STARTF_USESHOWWINDOW` and `SW_HIDE` in `STARTUPINFOEXW`, hiding it at creation. +The runner never calls `ShowWindow`, changes the active desktop or modifies +terminal settings. The intended boundary is one hidden console inherited by +ordinary descendants. The old launcher already used `CREATE_NO_WINDOW`. +The hosted original production seam did not reproduce a visible window through +either Rust `Command` or raw Win32 creation on interactive `WinSta0`. Its root, +child and grandchild reported null HWNDs with populated console-client lists. +A null HWND does not prove that a process lacks a console connection. This candidate is unqualified as a +fix for the reported popup until an actual original-seam visibility +counterexample and passing candidate evidence exist. +See Microsoft's [creation flags](https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags), +[console creation](https://learn.microsoft.com/en-us/windows/console/creation-of-a-console) +and [startup information](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/ns-processthreadsapi-startupinfow). + +Only `SystemCommandRunner` uses this native launch path. It covers both the +Gemini version check and usage query, plus Copilot's background GitHub CLI calls. +The existing login, app-server and browser adapters retain their launch flags +and stdio behavior. Their owned process creation shares a short launch mutex +with the native runner. It covers the interval from creating temporary +inheritable handles through closing every parent copy. Rust's own +[private spawn mutex](https://github.com/rust-lang/rust/blob/master/library/std/src/sys/process/windows.rs#L321) +does not include native `CreateProcessW`. An uncoordinated third-party or future +direct spawn in the same process can still race this interval. The handle list +restricts what our native child inherits; it cannot control another launcher's +inheritance policy. +The native launcher inherits the caller's primary token, environment, working +directory and desktop. It supplies an explicit handle list containing null input +and the two write pipes. Process and thread handles remain noninheritable. +The provider starts suspended, enters the same kill-on-close job used before, +then resumes through its retained primary thread handle. Failure to establish +the job or resume terminates the suspended root. Native launch handles, attribute +storage and parent copies of child pipe handles have RAII owners. +See [process creation](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw) +and [handle-list attributes](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute). + +This boundary covers ordinary console inheritance. It does not prevent a CLI +from explicitly requesting a separate console, detaching, showing GUI windows, +or asking a separately running broker to show UI. It is not a hostile-process +UI sandbox. Windows Terminal/default-terminal configurations need separate +qualification before deployment. `GetConsoleWindow` can return a message-only +handle for a pseudoconsole while another terminal displays its output, per +[Microsoft's contract](https://learn.microsoft.com/en-us/windows/console/getconsolewindow). +An invisible handle alone is not proof that such a terminal is hidden. +Microsoft's public console-host +[handoff decision](https://github.com/microsoft/terminal/blob/98c8c795ebc2560af55e72beba3fa5fd31ceef64/src/server/IoDispatchers.cpp#L154) +declines delegation for `STARTF_USESHOWWINDOW` with `SW_HIDE` and for an already +initialized inherited console. That supports the design, but does not establish +parity with a deployed Windows console host or qualify a terminal configuration. ## Output and deadline policy @@ -117,3 +170,100 @@ accepts the exact `-1` sentinel, while other invalid group fields remain errors. Workspace tests and CI run this harness on Windows, macOS and Linux. No account, credential store, browser, desktop input or live provider response is involved. + +The Windows console fixture starts a detached caller of the real +`SystemCommandRunner`. Its provider root starts an ordinary child, which starts +an ordinary grandchild; neither descendant sets creation flags or visibility +hints. Only the synthetic caller uses `DETACHED_PROCESS`, and it verifies both +a null HWND and an empty console-client query before invoking the runner. +This distinguishes the caller precondition from an invisible console connection. +All three provider processes report console HWND, visibility samples, owner, window class, +console-process membership, startup flags, window station and desktop. +It exercises both Rust `Command` and raw Win32 children with an explicit standard +handle list, `STARTF_USESTDHANDLES` and creation flags `0x80400`. Neither route +requests a new console or calls `FreeConsole` in a descendant. +Candidate mechanism qualification requires one valid hidden +`ConsoleWindowClass` HWND shared by the full chain, matching owner and desktop, +each ancestor in the console-client list, and enumeration on that desktop. +Null, invalid, foreign and message-only HWNDs cannot pass as hidden-window proof. +It also verifies the caller's user SID, logon/session, working directory and a +synthetic inherited environment value, then waits for the console window to +disappear after the final client exits. These are read-only queries of native +fixtures. No desktop switch, terminal preference change or provider invocation +is used. + +Additional Windows fixtures compare native argv and batch-shim behavior with +Rust's existing `Command`, count handles across repeated queries and abruptly +exit a runner with an owned descendant still running. The latter skips Rust +destructors and verifies Windows job-handle closure stops that descendant and +releases its locked resource. Existing finite-output, size-cap, stderr, exit, +null-input and timeout fixtures still run through the production collector. +The unrelated-pipe sentinel closes its intended writer while a provider still +holds a locked resource, and requires EOF before provider exit. A leaked +inheritable writer cannot pass through later job cleanup. A retained process +handle must also remain nonsignaled before and after EOF, independently of file-lock +release timing. A concurrent native +launch/legacy-launch guard check covers coordination with the existing launcher. + +The original launcher is retained in baseline commit +[`0000dda`](https://github.com/hemsoft-dev/codexbar/commit/0000dda1e5b7a2cdeb23fe9c8d3fb48f4d1a822d) +and its [completed Windows run](https://github.com/hemsoft-dev/codexbar/actions/runs/40172657125/job/121106593366) +passed, with all three HWNDs null. This is not visibility RED. The same head's +[macOS overflow fixture](https://github.com/hemsoft-dev/codexbar/actions/runs/40172657125/job/121106593295) +failed with `TimedOut` instead of `Failed`. The bulk-output cap check now has a +separate 15-second budget; hung-process cases retain their three-second deadline. +No Unix production collector semantics changed. + +`scripts/qualify-provider-console.py` runs only in hosted Windows CI. It builds +the current observer against exact original production seam bytes from +`7bb492fd3622727eee81b1e31f7256a15e71c3dd`, then against the candidate. It uses +separate Cargo target directories, retains source hashes and candidate backups, +and restores and verifies candidate bytes in `finally` before normal gates. +This compares the original production seam, not the entire old build or a +deployed image. Reports distinguish a visible descendant window on an +interactive station from no reproduction or noninteractive preconditions. +Null-HWND architecture differences and compile failures are never visibility +RED. Candidate shared-hidden-console assertions run separately in workspace +tests. Execution results and their exact revisions belong in +[PR #272](https://github.com/hemsoft-dev/codexbar/pull/272). Source inspection or +successful compilation alone does not establish RED or GREEN. This qualification +does not establish deployed-image parity, vendor re-exec behavior or game-crash +causality. Deployment remains a separate operator decision. + +The [`b132c8f` Windows run](https://github.com/hemsoft-dev/codexbar/actions/runs/40174761361/job/121112944158) +retained `restored: true` and `qualification: unqualified`. Both original routes +reported no reproduction on interactive `WinSta0`; both candidate routes reported +one valid, enumerated, shared hidden console. That is mechanism evidence, not +original visibility RED. Clippy then rejected the legacy `Contained::stop` method +as unused on Windows, so the full runtime test was skipped. The method is now +compiled only for the Unix runner that calls it; the native Windows runner keeps +its own bounded cleanup path. This preserves the observed failure and leaves +full current-head runtime qualification pending. + +The [`58a2ab2` Linux metrics run](https://github.com/hemsoft-dev/codexbar/actions/runs/40176058535/job/121116931821) +failed its declared comparison. Lines changed from 19981/22785 to 19980/22787; +the fully covered runner's measured complexity rose from four to five. Native +Windows functions had no instrumented Linux lines and therefore retained the +gate's intentionally pessimistic unknown coverage. No measurement scope, +exclusion, baseline or threshold is changed here. + +Windows executable selection and command-line planning now form a portable +production module, also compiled for tests on Linux and macOS. Tests cover +application/system/PATH priority, explicit executable identity, missing and +invalid paths, native versus batch interpreter selection, quotes/backslashes, +literal shell characters, Unicode and UTF-16 size limits. The hosted Windows +argv/batch fixtures remain the runtime compatibility check. The encoding follows +[Microsoft's CRT argument rules](https://learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments). + +Native prepared stdio owns its whitelist memory, inherited child handles and +launch guard together. Struct field order closes streams before releasing that +guard on error, following [Rust's drop rules](https://doc.rust-lang.org/reference/destructors.html). +A separate suspended-process owner holds the exact process/thread through job +assignment and resume; abandoning an uncommitted launch terminates that retained +process with bounded confirmation. Native tests exercise suspended abort and +failed-creation cleanup. They retain duplicate kernel-object handles and use +[`CompareObjectHandles`](https://learn.microsoft.com/en-us/windows/win32/api/handleapi/nf-handleapi-compareobjecthandles) +to avoid confusing reused handle values with leaked resources. The collector +uses one platform launch constructor; capture, deadlines, job/group cleanup and +the live unrelated-pipe sentinel remain required. New measurements and native +test results are pending hosted CI. diff --git a/scripts/qualify-provider-console.py b/scripts/qualify-provider-console.py new file mode 100644 index 00000000..0f40191d --- /dev/null +++ b/scripts/qualify-provider-console.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python3 +"""Hosted Windows only. Build both pinned seams before one bounded, non-repeating raw-chain experiment.""" +import hashlib +import json +import os +from pathlib import Path +import platform +import subprocess +import sys +import time + +BASE = "7bb492fd3622727eee81b1e31f7256a15e71c3dd" +SEAM = ["crates/codexbar-providers/src/command.rs", + "crates/codexbar-providers/src/command/pipe.rs", + "crates/codexbar-providers/src/contained.rs"] +FIXTURE = ["crates/codexbar-providers/tests/command_runner.rs", + "crates/codexbar-providers/tests/window_diagnostics.rs", + "crates/codexbar-providers/tests/window_binding.rs", + "crates/codexbar-providers/tests/command_runner/windows_console.rs", + *[f"crates/codexbar-providers/tests/command_runner/windows_console/{name}.rs" + for name in ("raw", "station", "identity", "observer", "hooks", "abrupt", "diagnostics", "binding", "binding_policy")], + "scripts/qualify-provider-console.py", "scripts/tests/test_provider_console.py"] +ROOT = Path(__file__).resolve().parent.parent +EXPERIMENT_SECONDS = 30 +OUTPUT_CAP = 16 * 1024 * 1024 + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def build(label, output): + folder = output / label + folder.mkdir() + # Original seam, candidate seam and normal gates never share compiled artifacts. + target = Path(os.environ["RUNNER_TEMP"]) / "codexbar-console-qualification" / label + env = dict(os.environ, CARGO_TARGET_DIR=str(target)) + command = ["cargo", "test", "-p", "codexbar-providers", "--test", "command_runner", + "--no-run", "--locked", "--message-format=json"] + with (folder / "build.log").open("wb") as log: + result = subprocess.run(command, cwd=ROOT, env=env, stdout=subprocess.PIPE, stderr=log) + (folder / "cargo.jsonl").write_bytes(result.stdout) + if result.returncode: + raise RuntimeError(f"{label} observer compilation failed, exit {result.returncode}; not visibility RED") + artifacts = [json.loads(line) for line in result.stdout.splitlines() if line.startswith(b"{")] + executables = [item["executable"] for item in artifacts + if item.get("reason") == "compiler-artifact" and item.get("executable") + and item["target"]["name"] == "command_runner"] + if len(executables) != 1: + raise RuntimeError(f"{label}: expected one freshly qualified fixture artifact") + return {"target_dir": str(target), "executable": executables[0], + "binary_sha256": sha(Path(executables[0]).read_bytes()), "observations": [], + "requested_root_flags_from_source": "CREATE_NO_WINDOW|CREATE_SUSPENDED; runtime adds flags" if label == "original-production-seam" else "0x80414", + "default_failure_state": {"ready": "unknown", "event_loss": "unknown", + "coverage": "unknown", "cleanup": "unknown"}} + + +def observe(label, output, built, deadline): + folder = output / label + # No pipe-drain wait after timeout: stdout/stderr go directly to bounded-evidence files. + # The outer fixture first owns a kill-on-close job containing only this inert experiment. + if sha(Path(built["executable"]).read_bytes()) != built["binary_sha256"]: + raise RuntimeError(f"{label}: fixture binary changed after build") + remaining = deadline - time.monotonic() + if remaining <= 1: + raise RuntimeError("30s total runtime budget exhausted before next trial; no repeat") + with (folder / "stdout.log").open("wb") as stdout, (folder / "stderr.log").open("wb") as stderr: + child = subprocess.Popen([built["executable"], "window-observe"], cwd=ROOT, + stdin=subprocess.DEVNULL, stdout=stdout, stderr=stderr) + try: + child.wait(timeout=min(14, remaining - 1)) + except subprocess.TimeoutExpired as error: + child.kill() # Retained Popen process identity only; its job closes on process termination. + try: + child.wait(timeout=min(1, max(0.001, deadline - time.monotonic()))) + except subprocess.TimeoutExpired as cleanup: + raise RuntimeError(f"{label}: retained fixture cleanup unconfirmed; coverage unknown") from cleanup + raise RuntimeError(f"{label}: observer timeout; coverage unknown, not visibility RED") from error + if time.monotonic() > deadline: + raise RuntimeError("30s total runtime budget exceeded; no qualification") + if sha(Path(built["executable"]).read_bytes()) != built["binary_sha256"]: + raise RuntimeError(f"{label}: fixture binary changed during experiment") + if any((folder / name).stat().st_size > OUTPUT_CAP for name in ("stdout.log", "stderr.log")): + raise RuntimeError(f"{label}: output event/protocol cap exceeded") + if child.returncode: + raise RuntimeError(f"{label}: observer failed, exit {child.returncode}; not visibility RED") + reports = [json.loads(line) for line in (folder / "stdout.log").read_bytes().splitlines() + if line.startswith(b"{")] + reports = [item for item in reports if item.get("result") == "visibility-observer"] + if len(reports) != 1 or reports[0].get("route") != "raw-handle-list": + raise RuntimeError(f"{label}: expected one raw Win32 observation report") + built["observations"] = reports + if not integrity(reports[0]): + failure = reports[0].get("failure") or reports[0].get("protocol_errors") or reports[0].get("observation", {}).get("errors") + raise RuntimeError(f"{label}: observer integrity failed, not visibility RED: {str(failure)[:1000]}") + + +def integrity(row): + observation = row.get("observation", {}) + return (row.get("ready") is True and row.get("failure") is None + and 0 < row.get("ready_ack_us", 0) <= 2_000_000 + and row.get("control", {}).get("independently_seen") is True + and row.get("control", {}).get("destroyed") is True + and row.get("control", {}).get("console_hwnd") == 0 + and 0 < row.get("control", {}).get("elapsed_us", 0) <= 1_000_000 + and row.get("protocol_errors") == [] + and observation.get("control_seen") is True and observation.get("errors") == [] + and observation.get("event_cap_hit") is False + and observation.get("cleanup_confirmed") is True + and len(row.get("rows", [])) == 3) + + +def binding_events_complete(row): + """Readback witnesses from the real hook/resolver, not summary success flags alone.""" + creator, generator = row.get("creator", {}), row.get("generator", {}) + registration = row.get("registration", {}) + selected = [] + for kind, actor in ((0x8002, generator), (0x8001, creator)): + matches = [event for event in row.get("observation", {}).get("events", []) + if event.get("phase") == "owned-lifecycle-binding-control" and event.get("event_kind") == kind + and event.get("hwnd") == registration.get("hwnd") + and event.get("creator_tid") == creator.get("tid") and event.get("generator_tid") == actor.get("tid") + and event.get("generator_thread_birth") == actor.get("thread_birth") + and event.get("registered_pid") == creator.get("pid") and event.get("process_birth") == creator.get("process_birth") + and event.get("fixture_generation") == registration.get("fixture_generation") == 1 + and event.get("mapping") == "cooperative-fixture-command-generation-only" + and event.get("original_visibility_red") is False and event.get("callback_sequence", 0) > 0] + if len(matches) != 1: + return False + selected.append(matches[0]) + native_negative = [event for event in row.get("observation", {}).get("events", []) + if event.get("phase") == "owned-lifecycle-binding-control" and event.get("event_kind") == 0x8002 + and event.get("hwnd") == registration.get("hwnd") + and event.get("creator_tid") == event.get("generator_tid") == creator.get("tid") + and event.get("generator_thread_birth") == creator.get("thread_birth") + and event.get("registered_pid") == creator.get("pid") and event.get("process_birth") == creator.get("process_birth") + and event.get("fixture_generation") == 1 and event.get("original_visibility_red") is False + and event.get("existing_resolver_error") is None + and event.get("mapping") == "rejected-real-creator-event-for-separate-generator-ticket" + and 0 < event.get("callback_sequence", 0) < selected[0]["callback_sequence"]] + return (len(native_negative) == 1 + and selected[0].get("existing_resolver_error") == "WinEvent identity differs from retained window owner/thread" + and selected[1].get("existing_resolver_error") is None + and selected[0]["callback_sequence"] < selected[1]["callback_sequence"]) + + +def binding_complete(row): + """Gate only the cooperative control. Its outcome is never original-provider RED or broker proof.""" + observation = row.get("observation", {}) + creator, generator = row.get("creator", {}), row.get("generator", {}) + context = observation.get("ready_context", {}) + return (row.get("complete") is True and "failure" in row and row.get("failure") is None and row.get("cleanup_confirmed") is True + and row.get("scope") == "cooperative-owned-fixture-only" and row.get("original_visibility_red") is False + and row.get("provider_or_broker_generation_proof") is False + and row.get("visible_control_readback") is True + and row.get("registration", {}).get("creator_api_readback_matches") is True + and row.get("registration", {}).get("hwnd", 0) > 0 and row.get("registration", {}).get("nonce", 0) > 0 + and 0 < row.get("ready_us", 0) <= 2_000_000 and 0 < row.get("window_lifetime_us", 0) < 1_000_000 + and 0 < row.get("elapsed_us", 0) <= 10_000_000 + and creator.get("pid", 0) > 0 and creator.get("pid") == generator.get("pid") + and creator.get("process_birth", 0) > 0 and creator.get("process_birth") == generator.get("process_birth") + and creator.get("tid", 0) > 0 and generator.get("tid", 0) > 0 and creator["tid"] != generator["tid"] + and creator.get("thread_birth", 0) > 0 and generator.get("thread_birth", 0) > 0 + and context.get("desktop") and context.get("desktop") == context.get("input_desktop") + and context.get("session", 0) > 0 and context.get("station", {}).get("visible") is True + and all(observation.get(field) is True for field in + ("native_show_mapped", "native_delayed_destroy_mapped", "legacy_resolver_show_rejected", + "legacy_resolver_destroy_accepted", "native_wrong_generator_rejected", "hook_closed")) + and observation.get("unattributable_registered_numeric_hwnd_events") == 0 + and observation.get("event_cap") == 8192 and observation.get("event_cap_hit") is False + and 0 < observation.get("event_count", 0) <= 8192 + and observation.get("event_loss") is False and observation.get("queue_drained") is True + and observation.get("destroy_readback", {}).get("window_query_return") == 0 + and observation.get("destroy_readback", {}).get("window_query_error", 0) > 0 + and observation.get("destroy_readback", {}).get("is_window") is False + and len(observation.get("negative_policy_controls", {}).get("outcomes", [])) == 6 + and {item.get("case") for item in observation["negative_policy_controls"]["outcomes"]} + == {"unknown-hwnd", "wrong-generation", "wrong-nonce", "wrong-thread-birth", "wrong-generator", "missing-live-owner"} + and all(item.get("rejected") is True for item in observation["negative_policy_controls"]["outcomes"]) + and binding_events_complete(row)) + + +def validate_binding(output, built, deadline): + folder = output / "owned-lifecycle-binding" + folder.mkdir() + result = {"binary_sha256": built["binary_sha256"], "observation": None, "complete": False} + receipt = folder / "report.json" + try: + if sha(Path(built["executable"]).read_bytes()) != built["binary_sha256"]: + raise RuntimeError("binding fixture binary changed after build") + remaining = deadline - time.monotonic() + if remaining <= 1: + raise RuntimeError("total 30s experiment budget exhausted before owned lifecycle control") + with (folder / "stdout.log").open("wb") as stdout, (folder / "stderr.log").open("wb") as stderr: + child = subprocess.Popen([built["executable"], "window-binding"], cwd=ROOT, + stdin=subprocess.DEVNULL, stdout=stdout, stderr=stderr) + try: + child.wait(timeout=min(10, remaining - 1)) + except subprocess.TimeoutExpired as error: + child.kill() # Its owned native kill-on-close job closes, never a global PID kill. + child.wait(timeout=min(1, max(0.001, deadline - time.monotonic()))) + raise RuntimeError("owned lifecycle control exceeded deadline; no comparison pair") from error + if child.returncode or any((folder / name).stat().st_size > OUTPUT_CAP for name in ("stdout.log", "stderr.log")): + raise RuntimeError("owned lifecycle control failed or exceeded output cap; no comparison pair") + rows = [json.loads(line) for line in (folder / "stdout.log").read_bytes().splitlines() if line.startswith(b"{")] + rows = [row for row in rows if row.get("result") == "owned-lifecycle-binding"] + if len(rows) != 1: + raise RuntimeError("missing owned lifecycle native receipt; no comparison pair") + result["observation"] = rows[0] + result["complete"] = binding_complete(rows[0]) + if sha(Path(built["executable"]).read_bytes()) != built["binary_sha256"]: + raise RuntimeError("binding fixture binary changed during observation") + if not result["complete"]: + raise RuntimeError(f"owned lifecycle validation incomplete; no comparison pair: {rows[0].get('failure')}") + if time.monotonic() > deadline: + raise RuntimeError("total experiment budget exceeded after owned lifecycle control") + return result + except BaseException as error: + result["failure"] = str(error) + raise + finally: + receipt.write_text(json.dumps(result, indent=2) + "\n") + + +def measured_coverage(row): + """Temporal/event coverage of owned fixture windows, never arbitrary broker coverage.""" + coverage = row.get("observation", {}).get("coverage", {}) + lives = row.get("observation", {}).get("lifetimes", []) + context = row.get("ready_context", {}) + start, end = coverage.get("start_100ns"), coverage.get("end_100ns") + if (not integrity(row) or coverage.get("scope") != "owned-fixture-top-level-windows" + or type(start) is not int or type(end) is not int or not 0 < start < end + or any(coverage.get(key) is not True for key in + ("hook_closed", "queue_drained", "control_show_event", "control_destroy_event", "full_lifetime", "window_lifecycle_complete")) + or coverage.get("unresolved_event_owners") != 0 or len(lives) != 3 + or context.get("session", 0) <= 0 or not context.get("desktop")): + return False + ordered = sorted(lives, key=lambda item: item.get("depth", -1)) + if [item.get("depth") for item in ordered] != [0, 1, 2]: + return False + pids = [item.get("pid") for item in ordered] + if any(type(pid) is not int or pid <= 0 for pid in pids) or len(set(pids)) != 3: + return False + for index, item in enumerate(ordered): + born, exited = item.get("creation_time"), item.get("exit_time") + parent = pids[index - 1] if index else context.get("caller_pid") + if (type(born) is not int or type(exited) is not int or not start <= born <= exited <= end + or item.get("parent_pid") != parent or item.get("session") != context.get("session") + or item.get("ack") is not True or item.get("retained_exit") is not True): + return False + return True + + +def visible_owned_window(row): + lives = row.get("observation", {}).get("lifetimes", []) + identities = {(item.get("pid"), item.get("creation_time")) for item in lives} + for event in row.get("observation", {}).get("events", []): + rect = event.get("rect", []) + if (event.get("phase") == "provider" and event.get("visible") is True + and (event.get("owner_pid"), event.get("owner_creation_time")) in identities + and event.get("hwnd", 0) > 0 and event.get("class") + and event.get("desktop") == row.get("ready_context", {}).get("desktop") + and len(rect) == 4 and rect[2] > rect[0] and rect[3] > rect[1]): + return True + return False + + +def hidden_association(row): + rows = row.get("rows", []) + if len(rows) != 3: + return False + hwnd, owner = rows[0].get("hwnd"), rows[0].get("owner") + if type(hwnd) is not int or hwnd <= 0 or type(owner) is not int or owner <= 0: + return False + # GetWindowThreadProcessId identifies the window-creating process, which can be a separate + # console host. Shared client-reported console association does not attribute broker windows + # to a client lifetime; visible_owned_window keeps that independent identity requirement. + return all(item.get("valid") is True and item.get("enumerated") is True + and item.get("visible") is False and item.get("class") == "ConsoleWindowClass" + and type(item.get("hwnd")) is int and item.get("hwnd") == hwnd + and type(item.get("owner")) is int and item.get("owner") == owner + and item.get("desktop") == item.get("window_desktop") == row.get("ready_context", {}).get("desktop") + for item in rows) + + +def classify(before, after): + # Positive control, mechanism association and no-reproduction never substitute for original RED. + red = integrity(before) and visible_owned_window(before) + hidden = integrity(after) and hidden_association(after) + complete = (integrity(before) and integrity(after) + and before.get("full_observation") is True and after.get("full_observation") is True) + complete = complete and measured_coverage(before) and measured_coverage(after) + coverage = [row.get("observation", {}).get("coverage", {}) for row in (before, after)] + green = complete and hidden and not visible_owned_window(after) + return {"qualification": "red-green" if red and green else "unqualified", + "original_visibility_counterexample": red, "candidate_shared_hidden_console": hidden, + "full_observation": complete, "outside_broker_coverage": [item.get("outside_broker", "unknown") for item in coverage], + "qualification_scope": "owned-fixture-top-level-windows", "stop_after_pair": True} + + +def main(): + if os.name != "nt" or os.environ.get("GITHUB_ACTIONS") != "true": + raise RuntimeError("This qualification may run only in hosted Windows GitHub CI") + subprocess.run([sys.executable, str(ROOT / "scripts/tests/test_provider_console.py")], cwd=ROOT, check=True) + output = ROOT / "artifacts/provider-console" + output.mkdir(parents=True, exist_ok=False) + candidate = {name: (ROOT / name).read_bytes() for name in SEAM} + original = {name: subprocess.check_output(["git", "show", f"{BASE}:{name}"], cwd=ROOT) for name in SEAM} + backup = output / "candidate-source" + backup.mkdir() + for name, data in candidate.items(): + path = backup / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + report = {"schema": 2, "original_seam_commit": BASE, + "default_failure_state": {"ready": "unknown", "event_loss": "unknown", "coverage": "unknown", "cleanup": "unknown"}, + "checkout_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(), + "candidate_head": os.environ["CONSOLE_CANDIDATE_HEAD"], + "candidate_sha256": {name: sha(data) for name, data in candidate.items()}, + "original_sha256": {name: sha(data) for name, data in original.items()}, + "fixture_sha256": {name: sha((ROOT / name).read_bytes()) for name in FIXTURE}, + "os_build": platform.version(), "machine": platform.machine(), + "rustc": subprocess.check_output(["rustc", "-Vv"], text=True), + "experiment_limit_seconds": EXPERIMENT_SECONDS, "build_time_in_runtime_budget": False, + "qualification": "unqualified", "restored": False} + manifest = output / "report.json" + manifest.write_text(json.dumps(report, indent=2) + "\n") + try: + try: + for name, data in original.items(): + (ROOT / name).write_bytes(data) + if any((ROOT / name).read_bytes() != data for name, data in original.items()): + raise RuntimeError("original production seam bytes do not match pinned Git objects") + report["original"] = build("original-production-seam", output) + finally: + # Restore/verify exact candidate bytes BEFORE candidate build or either runtime observation. + failures = [] + for name, data in candidate.items(): + try: + (ROOT / name).write_bytes(data) + if (ROOT / name).read_bytes() != data: + failures.append(name) + except OSError as error: + failures.append(f"{name}: {error}") + if failures: + raise RuntimeError(f"candidate restoration failed: {failures}") + report["restored"] = True + subprocess.run(["git", "diff", "--exit-code", "--", *SEAM], cwd=ROOT, check=True) + report["candidate"] = build("candidate", output) + # Exactly one original/candidate raw pair after both binaries exist. No retry or OS-setting changes. + deadline = time.monotonic() + EXPERIMENT_SECONDS + # A cooperative control proves its own registered mapping only. Never run the comparison if incomplete. + report["owned_lifecycle_binding"] = validate_binding(output, report["candidate"], deadline) + for label, key in (("original-production-seam", "original"), ("candidate", "candidate")): + observe(label, output, report[key], deadline) + before, after = (report[key]["observations"][0] for key in ("original", "candidate")) + report.update(classify(before, after)) + if time.monotonic() > deadline: + raise RuntimeError("30s total experiment budget exceeded during report processing") + if after.get("visibility_counterexample") is True: + raise RuntimeError("candidate has an attributable visible owned window") + print(f"Visibility qualification: {report['qualification']}; no further experiment.", flush=True) + except BaseException as error: + report["qualification"] = "unqualified" + report["failure"] = str(error) + raise + finally: + manifest.write_text(json.dumps(report, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_provider_console.py b/scripts/tests/test_provider_console.py new file mode 100644 index 00000000..e5656b9d --- /dev/null +++ b/scripts/tests/test_provider_console.py @@ -0,0 +1,264 @@ +"""Portable protocol tests only. Importing the orchestration module launches no process or GUI.""" +import copy +import importlib.util +from pathlib import Path +import unittest + +SPEC = importlib.util.spec_from_file_location("provider_console", Path(__file__).parents[1] / "qualify-provider-console.py") +POLICY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(POLICY) + + +def fixture(visible=False, console_owner=900): + lives = [{"depth": depth, "pid": 100 + depth, "parent_pid": 99 + depth, + "creation_time": 200 + depth, "exit_time": 800 - depth, + "session": 2, "ack": True, "retained_exit": True} for depth in range(3)] + rows = [{"depth": depth, "pid": 100 + depth, "hwnd": 42, "owner": console_owner, + "valid": True, "enumerated": True, "visible": False, + "class": "ConsoleWindowClass", "desktop": "Default", "window_desktop": "Default"} + for depth in range(3)] + # Independent owned-window evidence is separate from the client-reported shared console HWND. + events = [{"phase": "provider", "source": "winevent", "hwnd": 77, + "owner_pid": 100, "owner_creation_time": 200, "visible": visible, + "class": "ConsoleWindowClass", "desktop": "Default", "rect": [0, 0, 100, 100]}] + return {"ready": True, "ready_ack_us": 1000, "failure": None, "protocol_errors": [], + "control": {"independently_seen": True, "destroyed": True, "console_hwnd": 0, "elapsed_us": 1000}, + "ready_context": {"session": 2, "desktop": "Default", "caller_pid": 99}, + "rows": rows, "visibility_counterexample": visible, "shared_hidden_console": True, + "full_observation": True, + "observation": {"control_seen": True, "errors": [], "event_cap_hit": False, + "cleanup_confirmed": True, "lifetimes": lives, "events": events, + "coverage": {"scope": "owned-fixture-top-level-windows", "start_100ns": 100, + "end_100ns": 900, "hook_closed": True, "queue_drained": True, + "control_show_event": True, "control_destroy_event": True, + "unresolved_event_owners": 0, "outside_broker": "unknown", "full_lifetime": True, + "window_lifecycle_complete": True}}} + + +def binding_receipt(): + return {"complete": True, "failure": None, "cleanup_confirmed": True, + "scope": "cooperative-owned-fixture-only", "original_visibility_red": False, + "provider_or_broker_generation_proof": False, "visible_control_readback": True, + "registration": {"creator_api_readback_matches": True, "hwnd": 42, "nonce": 7, "fixture_generation": 1}, + "ready_us": 1000, "window_lifetime_us": 1000, "elapsed_us": 2000, + "creator": {"pid": 100, "process_birth": 200, "tid": 300, "thread_birth": 400}, + "generator": {"pid": 100, "process_birth": 200, "tid": 301, "thread_birth": 401}, + "observation": {"native_show_mapped": True, "native_delayed_destroy_mapped": True, + "legacy_resolver_show_rejected": True, "legacy_resolver_destroy_accepted": True, + "native_wrong_generator_rejected": True, + "hook_closed": True, "unattributable_registered_numeric_hwnd_events": 0, + "ready_context": {"desktop": "Default", "input_desktop": "Default", "session": 2, + "station": {"visible": True}}, + "event_count": 4, "event_cap": 8192, "event_cap_hit": False, + "event_loss": False, "queue_drained": True, + "destroy_readback": {"window_query_return": 0, "window_query_error": 1400, "is_window": False}, + "negative_policy_controls": {"outcomes": [{"case": name, "rejected": True} for name in + ("unknown-hwnd", "wrong-generation", "wrong-nonce", "wrong-thread-birth", "wrong-generator", "missing-live-owner")]}, + "events": [{"phase": "owned-lifecycle-binding-control", "event_kind": kind, + "hwnd": 42, "creator_tid": 300, "generator_tid": tid, "generator_thread_birth": birth, + "registered_pid": 100, "process_birth": 200, "fixture_generation": 1, + "mapping": mapping, "original_visibility_red": False, + "callback_sequence": sequence, "existing_resolver_error": error} + for kind, tid, birth, sequence, error, mapping in + ((0x8002, 301, 401, 3, "WinEvent identity differs from retained window owner/thread", + "cooperative-fixture-command-generation-only"), + (0x8001, 300, 400, 5, None, "cooperative-fixture-command-generation-only"), + (0x8002, 300, 400, 2, None, "rejected-real-creator-event-for-separate-generator-ticket"))]}} + + +class ObserverPolicy(unittest.TestCase): + def test_cooperative_binding_gate_requires_complete_native_control_receipt(self): + self.assertTrue(POLICY.binding_complete(binding_receipt())) + for field in binding_receipt(): + with self.subTest(missing=field): + row = binding_receipt() + del row[field] + self.assertFalse(POLICY.binding_complete(row)) + + def test_binding_control_unknown_or_wrong_identity_prevents_comparison(self): + for change in ({"unattributable_registered_numeric_hwnd_events": 1}, {"native_show_mapped": False}, + {"native_delayed_destroy_mapped": False}, {"legacy_resolver_show_rejected": False}, + {"hook_closed": False}, {"negative_policy_controls": {"outcomes": []}}): + row = binding_receipt() + row["observation"].update(change) + self.assertFalse(POLICY.binding_complete(row)) + row = binding_receipt() + row["generator"]["tid"] = row["creator"]["tid"] + self.assertFalse(POLICY.binding_complete(row)) + row = binding_receipt() + row["generator"]["thread_birth"] = 0 + self.assertFalse(POLICY.binding_complete(row)) + for events in ([], binding_receipt()["observation"]["events"][:1]): + row = binding_receipt() + row["observation"]["events"] = events + self.assertFalse(POLICY.binding_complete(row)) + for key, value in (("generator_tid", 300), ("generator_thread_birth", 999), + ("callback_sequence", 6), ("existing_resolver_error", "unrelated API failure")): + row = binding_receipt() + row["observation"]["events"][0][key] = value + self.assertFalse(POLICY.binding_complete(row)) + + def test_binding_control_never_substitutes_for_provider_red_or_waives_limits(self): + for change in ({"original_visibility_red": True}, {"provider_or_broker_generation_proof": True}, + {"ready_us": 2_000_001}, {"window_lifetime_us": 1_000_001}, + {"elapsed_us": 10_000_001}, {"cleanup_confirmed": False}): + row = binding_receipt() + row.update(change) + self.assertFalse(POLICY.binding_complete(row)) + original = fixture() + original["binding"] = binding_receipt() + self.assertEqual(POLICY.classify(original, fixture())["qualification"], "unqualified") + + def test_binding_control_requires_real_negative_event_and_exact_named_policy_controls(self): + for change in ({"event_kind": 0x8001}, {"generator_tid": 999}, {"callback_sequence": 9}, + {"generator_thread_birth": 999}, {"existing_resolver_error": "API failure"}): + row = binding_receipt() + row["observation"]["events"][2].update(change) + self.assertFalse(POLICY.binding_complete(row)) + row = binding_receipt() + row["observation"]["negative_policy_controls"]["outcomes"][0]["case"] = "wrong-generator" + self.assertFalse(POLICY.binding_complete(row)) + + def test_binding_control_missing_desktop_or_event_loss_remains_incomplete(self): + for change in ({"event_count": 8193}, {"event_cap_hit": True}, {"event_cap": 8193}, + {"event_loss": "unknown"}, {"queue_drained": False}, + {"ready_context": {}}, {"ready_context": {"desktop": "other", "input_desktop": "Default"}}): + row = binding_receipt() + row["observation"].update(change) + self.assertFalse(POLICY.binding_complete(row)) + + def test_shared_hidden_console_host_can_differ_from_all_client_pids(self): + candidate = fixture() + self.assertNotIn(candidate["rows"][0]["owner"], [item["pid"] for item in candidate["rows"]]) + self.assertTrue(POLICY.hidden_association(candidate)) + self.assertEqual(POLICY.classify(fixture(True), candidate)["qualification"], "red-green") + + def test_shared_console_host_does_not_attribute_a_separate_visible_broker_window(self): + original = fixture() + original["observation"]["events"][0].update(owner_pid=900, owner_creation_time=999, visible=True) + self.assertFalse(POLICY.visible_owned_window(original)) + self.assertEqual(POLICY.classify(original, fixture())["qualification"], "unqualified") + + def test_shared_owner_still_allows_the_root_client_to_create_the_window(self): + self.assertTrue(POLICY.hidden_association(fixture(console_owner=100))) + + def test_invalid_shared_host_and_handle_are_rejected(self): + for field in ("owner", "hwnd"): + for value in (None, 0, -1, "900", True, 900.0): + with self.subTest(field=field, value=value): + candidate = fixture() + for row in candidate["rows"]: + row[field] = value + self.assertFalse(POLICY.hidden_association(candidate)) + + def test_shared_host_does_not_weaken_window_validation(self): + for field, value in (("owner", 901), ("hwnd", 43), ("valid", False), + ("enumerated", False), ("visible", True), ("class", "OtherWindow"), + ("desktop", "OtherDesktop"), ("window_desktop", "OtherDesktop")): + with self.subTest(field=field): + candidate = fixture() + candidate["rows"][1][field] = value + self.assertFalse(POLICY.hidden_association(candidate)) + for field in ("owner", "hwnd", "valid", "enumerated", "class", "desktop", "window_desktop"): + with self.subTest(missing=field): + candidate = fixture() + del candidate["rows"][1][field] + self.assertFalse(POLICY.hidden_association(candidate)) + + def test_attributable_root_red_and_measured_owned_green(self): + report = POLICY.classify(fixture(True), fixture()) + self.assertEqual(report["qualification"], "red-green") + self.assertEqual(report["qualification_scope"], "owned-fixture-top-level-windows") + self.assertEqual(report["outside_broker_coverage"], ["unknown", "unknown"]) + + def test_no_reproduction_and_control_are_never_red(self): + original = fixture() + original["observation"]["events"][0].update(phase="positive-control", visible=True) + original["visibility_counterexample"] = True # Summary booleans cannot override event ownership. + self.assertEqual(POLICY.classify(original, fixture())["qualification"], "unqualified") + + def test_foreign_owner_and_reused_pid_creation_are_not_red(self): + for change in ({"owner_pid": 777}, {"owner_creation_time": 201}, {"hwnd": 0}, + {"class": ""}, {"desktop": "other"}, {"rect": [0, 0, 0, 0]}): + with self.subTest(change=change): + original = fixture(True) + original["observation"]["events"][0].update(change) + self.assertFalse(POLICY.visible_owned_window(original)) + + def test_missing_coverage_field_fails_closed(self): + for field in ("start_100ns", "end_100ns", "scope", "hook_closed", "queue_drained", + "control_show_event", "control_destroy_event", "unresolved_event_owners", "full_lifetime", "window_lifecycle_complete"): + with self.subTest(field=field): + candidate = fixture() + del candidate["observation"]["coverage"][field] + self.assertEqual(POLICY.classify(fixture(True), candidate)["qualification"], "unqualified") + + def test_false_ack_and_unknown_ownership_fail_closed(self): + for field, value in (("hook_closed", False), ("queue_drained", False), + ("control_show_event", False), ("control_destroy_event", False), + ("unresolved_event_owners", 1), ("start_100ns", 300), ("end_100ns", 700)): + with self.subTest(field=field): + candidate = fixture() + candidate["observation"]["coverage"][field] = value + self.assertFalse(POLICY.measured_coverage(candidate)) + + def test_lost_process_lifetime_and_topology_fail_closed(self): + for change in ({"creation_time": 99}, {"exit_time": 0}, {"exit_time": 901}, + {"retained_exit": False}, {"ack": False}, {"parent_pid": 777}, + {"session": 0}, {"depth": 0}, {"pid": 100}): + with self.subTest(change=change): + candidate = fixture() + candidate["observation"]["lifetimes"][1].update(change) + self.assertEqual(POLICY.classify(fixture(True), candidate)["qualification"], "unqualified") + + def test_errors_caps_control_deadlines_and_early_exit_fail_closed(self): + for change in ({"errors": ["lost event"]}, {"event_cap_hit": True}, {"cleanup_confirmed": False}): + candidate = fixture() + candidate["observation"].update(change) + self.assertEqual(POLICY.classify(fixture(True), candidate)["qualification"], "unqualified") + for change in ({"failure": "early exit"}, {"protocol_errors": ["missing sample"]}, + {"full_observation": False}, {"ready_ack_us": 2_000_001}, {"ready": False}): + candidate = fixture() + candidate.update(change) + self.assertEqual(POLICY.classify(fixture(True), candidate)["qualification"], "unqualified") + for change in ({"elapsed_us": 1_000_001}, {"destroyed": False}, {"console_hwnd": 3}): + candidate = fixture() + candidate["control"].update(change) + self.assertEqual(POLICY.classify(fixture(True), candidate)["qualification"], "unqualified") + + def test_clock_reversal_and_missing_root_cannot_claim_coverage(self): + candidate = fixture() + candidate["observation"]["coverage"].update(start_100ns=999, end_100ns=900) + self.assertFalse(POLICY.measured_coverage(candidate)) + candidate = fixture() + candidate["observation"]["lifetimes"].pop(0) + self.assertFalse(POLICY.measured_coverage(candidate)) + + def test_candidate_visible_or_mismatched_shared_owner_is_not_green(self): + for candidate in (fixture(True), fixture()): + if not candidate["visibility_counterexample"]: + candidate["rows"][0]["owner"] = 777 + self.assertEqual(POLICY.classify(fixture(True), candidate)["qualification"], "unqualified") + + def test_both_invisible_is_unqualified_without_mutating_evidence(self): + original, candidate = fixture(), fixture() + saved = copy.deepcopy((original, candidate)) + self.assertEqual(POLICY.classify(original, candidate)["qualification"], "unqualified") + self.assertEqual((original, candidate), saved) + + def test_measurement_diagnostics_cannot_override_unknown_or_lost_events(self): + for coverage in ({"unresolved_event_owners": 2}, {"window_lifecycle_complete": False}): + candidate = fixture() + candidate["observation"]["coverage"].update(coverage) + candidate["observation"]["resolver_diagnostics"] = { + "counter_increment_preceded_diagnostic": True, "events_with_fresh_owned_cache_observation": [{}], + "historical_owned_cache": [{}], "decisions": 100, + } + self.assertEqual(POLICY.classify(fixture(True), candidate)["qualification"], "unqualified") + candidate = fixture() + candidate["observation"].update(errors=["callback queue lost events"], resolver_diagnostics={"decisions": 100}) + self.assertEqual(POLICY.classify(fixture(True), candidate)["qualification"], "unqualified") + + +if __name__ == "__main__": + unittest.main()