#[cfg(test)]
mod probes {
use crate::cockpit::ansi::TranscriptStream;
use std::ffi::c_void;
use std::io::{IsTerminal, Write};
use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, HANDLE};
use windows_sys::Win32::Storage::FileSystem::{
CreateFileW, GetFileType, ReadFile, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TYPE_CHAR,
FILE_TYPE_PIPE, OPEN_EXISTING,
};
use windows_sys::Win32::System::Console::{
AllocConsole, ClosePseudoConsole, CreatePseudoConsole, FreeConsole, GetConsoleMode,
GetStdHandle, SetStdHandle, COORD, HPCON, STD_ERROR_HANDLE, STD_INPUT_HANDLE,
STD_OUTPUT_HANDLE,
};
use windows_sys::Win32::System::Pipes::CreatePipe;
use windows_sys::Win32::System::Threading::{
CreateProcessW, DeleteProcThreadAttributeList, GetExitCodeProcess,
InitializeProcThreadAttributeList, UpdateProcThreadAttribute, WaitForSingleObject,
EXTENDED_STARTUPINFO_PRESENT, INFINITE, PROCESS_INFORMATION,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, STARTUPINFOEXW,
};
const ROLE_ENV: &str = "NEWT_CONPTY_ROLE";
const RESULT_ENV: &str = "NEWT_CONPTY_RESULT";
const OUT_MARKER: &str = "NEWT_CONPTY_STDOUT_7Z";
const ERR_MARKER: &str = "NEWT_CONPTY_STDERR_7Z";
fn create_pipe() -> (HANDLE, HANDLE) {
let mut read: HANDLE = std::ptr::null_mut();
let mut write: HANDLE = std::ptr::null_mut();
let ok = unsafe { CreatePipe(&mut read, &mut write, std::ptr::null(), 0) };
assert!(ok != 0, "CreatePipe failed: {}", last_error());
(read, write)
}
fn last_error() -> u32 {
unsafe { GetLastError() }
}
fn close(h: HANDLE) {
if !h.is_null() {
unsafe {
CloseHandle(h);
}
}
}
fn read_to_end(handle: HANDLE) -> Vec<u8> {
let mut out = Vec::new();
let mut buf = [0u8; 4096];
loop {
let mut n: u32 = 0;
let ok = unsafe {
ReadFile(
handle,
buf.as_mut_ptr(),
buf.len() as u32,
&mut n,
std::ptr::null_mut(),
)
};
if ok == 0 || n == 0 {
break;
}
out.extend_from_slice(&buf[..n as usize]);
}
out
}
fn open_con(name: &str) -> HANDLE {
let wide: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
unsafe {
CreateFileW(
wide.as_ptr(),
0xC000_0000, FILE_SHARE_READ | FILE_SHARE_WRITE,
std::ptr::null(),
OPEN_EXISTING,
0,
std::ptr::null_mut(),
)
}
}
fn self_exe() -> std::path::PathBuf {
std::env::current_exe().expect("current_exe")
}
fn result_path() -> std::path::PathBuf {
std::env::temp_dir().join("newt_conpty_probe_result.bin")
}
#[serial_test::serial(tty_arbiter)]
#[ignore = "real-resource evidence probe: swaps this process's global STD_OUTPUT_HANDLE, so it must run single-threaded in the dedicated CI step, not the parallel workspace suite"]
#[test]
fn probe_a_pipe_stdout_is_not_a_console() {
let (read, write) = create_pipe();
let saved = unsafe { GetStdHandle(STD_OUTPUT_HANDLE) };
let set = unsafe { SetStdHandle(STD_OUTPUT_HANDLE, write) };
assert!(set != 0, "SetStdHandle failed: {}", last_error());
let file_type = unsafe { GetFileType(write) };
let mut mode = 0u32;
let console_ok = unsafe { GetConsoleMode(write, &mut mode) };
unsafe {
SetStdHandle(STD_OUTPUT_HANDLE, saved);
}
close(read);
close(write);
assert_eq!(
file_type, FILE_TYPE_PIPE,
"a redirected-to-pipe stdout is a pipe, not FILE_TYPE_CHAR ({FILE_TYPE_CHAR})"
);
assert_eq!(
console_ok, 0,
"GetConsoleMode must FAIL on a pipe — is_terminal() would be false"
);
}
#[serial_test::serial(tty_arbiter)]
#[ignore = "real-resource evidence probe: spawns a ConPTY host subprocess, so it must run single-threaded in the dedicated CI step, not the parallel workspace suite"]
#[test]
fn probe_b_child_stdout_and_stderr_traverse_the_conpty() {
let result = result_path();
let _ = std::fs::remove_file(&result);
let status = std::process::Command::new(self_exe())
.args([
"--exact",
"cockpit::conpty_probe::probes::conpty_host",
"--ignored",
"--nocapture",
"--test-threads=1",
])
.env(ROLE_ENV, "host")
.env(RESULT_ENV, &result)
.status()
.expect("spawn conpty host");
assert!(status.success(), "host subprocess failed: {status:?}");
let rendered = std::fs::read(&result).expect("host wrote no result file");
let _ = std::fs::remove_file(&result);
let split = rendered.iter().position(|&b| b == b'\n').unwrap_or(0);
let header = String::from_utf8_lossy(&rendered[..split]).into_owned();
let pty = &rendered[(split + 1).min(rendered.len())..];
let text = String::from_utf8_lossy(pty);
if !header.contains("allocated=true") {
eprintln!(
"probe_b: host could not acquire a console on this runner ({header}); \
pty-traversal assertion skipped"
);
return;
}
let want_out = format!("{OUT_MARKER}[tty=true]");
let want_err = format!("{ERR_MARKER}[tty=true]");
assert!(
text.contains(&want_out),
"child stdout must traverse the ConPTY as a terminal.\nheader: {header}\npty: {text:?}"
);
assert!(
text.contains(&want_err),
"child stderr must traverse the ConPTY as a terminal.\nheader: {header}\npty: {text:?}"
);
let mut stream = TranscriptStream::new();
let mut lines: Vec<String> = Vec::new();
let mut passthrough: Vec<u8> = Vec::new();
for chunk in pty.chunks(7) {
let d = stream.feed(chunk);
lines.extend(
d.lines
.iter()
.map(|l| String::from_utf8_lossy(l).into_owned()),
);
passthrough.extend(d.passthrough);
}
lines.push(String::from_utf8_lossy(stream.partial()).into_owned());
assert!(
lines.iter().any(|l| l.contains(OUT_MARKER)),
"ansi::TranscriptStream should surface the child's stdout line; got {lines:?}"
);
let pass = String::from_utf8_lossy(&passthrough);
for banned in ["9001", "1004", "?25", "1049", "2004", "?7h", "?7l"] {
assert!(
!pass.contains(banned),
"the #3 allowlist must drop the pseudoconsole's {banned} mode; passthrough={pass:?}"
);
}
}
#[ignore = "real-resource evidence probe: spawns a ConPTY host subprocess, so it must run single-threaded in the dedicated CI step, not the parallel workspace suite"]
#[test]
fn probe_c_is_a_process_global_console_required() {
let result = result_path().with_extension("nocon");
let _ = std::fs::remove_file(&result);
let out = std::process::Command::new(self_exe())
.args([
"--exact",
"cockpit::conpty_probe::probes::conpty_host",
"--ignored",
"--nocapture",
"--test-threads=1",
])
.env(ROLE_ENV, "host-nocon")
.env(RESULT_ENV, &result)
.output()
.expect("spawn conpty host (no console)");
let host_stdio = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let rendered = std::fs::read(&result).unwrap_or_default();
let _ = std::fs::remove_file(&result);
let split = rendered.iter().position(|&b| b == b'\n').unwrap_or(0);
let header = String::from_utf8_lossy(&rendered[..split]).into_owned();
let pty =
String::from_utf8_lossy(&rendered[(split + 1).min(rendered.len())..]).into_owned();
assert!(
out.status.success(),
"host (no console) subprocess failed: {:?}\n{host_stdio}",
out.status
);
assert!(
header.contains("allocated=false"),
"probe C must run WITHOUT acquiring a console; header: {header:?}"
);
assert!(
header.contains("created=1"),
"the ConPTY child must have been created for the result to mean \
anything; header: {header:?}"
);
let want_out = format!("{OUT_MARKER}[tty=true]");
let want_err = format!("{ERR_MARKER}[tty=true]");
let through_pty = pty.contains(&want_out) && pty.contains(&want_err);
let leaked = host_stdio.contains(OUT_MARKER) || host_stdio.contains(ERR_MARKER);
let verdict = if through_pty && !leaked {
"traversed"
} else if leaked {
"leaked"
} else {
"absent"
};
println!("NEWT_CONPTY_PROBE_C verdict={verdict} header={header:?}");
println!("NEWT_CONPTY_PROBE_C pty={pty:?}");
println!("NEWT_CONPTY_PROBE_C host_stdio_has_markers={leaked}");
assert_eq!(
verdict, "leaked",
"probe C measured {verdict}, not the recorded `leaked`. Re-open the \
console question in docs/decisions/windows_cockpit_conpty.md.\n\
header: {header}\npty: {pty:?}"
);
assert!(
header.contains("child_exit=0"),
"the child must exit cleanly for the verdict to be trustworthy; \
header: {header:?}"
);
}
#[ignore = "spawned by probe_b as the ConPTY host; not a standalone test"]
#[test]
fn conpty_host() {
let role = std::env::var(ROLE_ENV);
let acquire_console = match role.as_deref() {
Ok("host") => true,
Ok("host-nocon") => false,
_ => return,
};
let result = std::env::var(RESULT_ENV).expect("RESULT path");
let allocated = unsafe {
if !acquire_console {
false
} else {
FreeConsole();
let ok = AllocConsole() != 0;
if ok {
let conout = open_con("CONOUT$");
let conin = open_con("CONIN$");
SetStdHandle(STD_OUTPUT_HANDLE, conout);
SetStdHandle(STD_ERROR_HANDLE, conout);
SetStdHandle(STD_INPUT_HANDLE, conin);
}
ok
}
};
let (in_read, in_write) = create_pipe();
let (out_read, out_write) = create_pipe();
let size = COORD { X: 80, Y: 24 };
let mut hpc: HPCON = 0;
let hr = unsafe { CreatePseudoConsole(size, in_read, out_write, 0, &mut hpc) };
let mut attr_size: usize = 0;
let (attr_list, _attr_buf) = unsafe {
InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut attr_size);
let mut buf = vec![0u8; attr_size];
let list = buf.as_mut_ptr() as *mut c_void;
InitializeProcThreadAttributeList(list, 1, 0, &mut attr_size);
UpdateProcThreadAttribute(
list,
0,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE as usize,
hpc as *const c_void,
std::mem::size_of::<HPCON>(),
std::ptr::null_mut(),
std::ptr::null_mut(),
);
(list, buf)
};
let cmdline = format!(
"\"{}\" --exact cockpit::conpty_probe::probes::conpty_child --ignored --nocapture",
self_exe().display()
);
let mut cmdline_w: Vec<u16> = cmdline.encode_utf16().chain(std::iter::once(0)).collect();
let mut si: STARTUPINFOEXW = unsafe { std::mem::zeroed() };
si.StartupInfo.cb = std::mem::size_of::<STARTUPINFOEXW>() as u32;
si.lpAttributeList = attr_list;
let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
std::env::set_var(ROLE_ENV, "child");
let created = unsafe {
CreateProcessW(
std::ptr::null(),
cmdline_w.as_mut_ptr(),
std::ptr::null(),
std::ptr::null(),
0,
EXTENDED_STARTUPINFO_PRESENT,
std::ptr::null(),
std::ptr::null(),
&si.StartupInfo,
&mut pi,
)
};
unsafe {
CloseHandle(in_read);
CloseHandle(out_write);
}
let out_read_val = out_read as usize;
let reader = std::thread::spawn(move || read_to_end(out_read_val as HANDLE));
let mut child_exit: u32 = u32::MAX;
unsafe {
if created != 0 {
WaitForSingleObject(pi.hProcess, INFINITE);
GetExitCodeProcess(pi.hProcess, &mut child_exit);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
std::thread::sleep(std::time::Duration::from_millis(500));
}
ClosePseudoConsole(hpc);
}
let rendered = reader.join().expect("reader");
unsafe {
DeleteProcThreadAttributeList(attr_list);
}
close(in_write);
close(out_read);
let mut file = Vec::new();
file.extend_from_slice(
format!("allocated={allocated} hr={hr} created={created} child_exit={child_exit}\n")
.as_bytes(),
);
file.extend_from_slice(&rendered);
std::fs::write(&result, file).expect("write result");
std::process::exit(0);
}
#[ignore = "spawned by conpty_host under the ConPTY; not a standalone test"]
#[test]
fn conpty_child() {
if std::env::var(ROLE_ENV).as_deref() != Ok("child") {
return;
}
let out_tty = std::io::stdout().is_terminal();
let err_tty = std::io::stderr().is_terminal();
print!("{OUT_MARKER}[tty={out_tty}]");
let _ = std::io::stdout().flush();
eprint!("{ERR_MARKER}[tty={err_tty}]");
let _ = std::io::stderr().flush();
std::process::exit(0);
}
}