diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d6b51..70070a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ Versioning with Cargo's pre-1.0 compatibility rules. ## [Unreleased] +### Changed + +- Strengthened process creation with private running/suspended typestates, + unified handle-transfer ownership, and value-based pseudoconsole storage. +- Aligned ConPTY startup with the Windows reference sequence by leaving + ordinary standard handles unused and omitting `STARTF_USESTDHANDLES`. +- Require `SuspendedChild::resume` to observe the expected suspend count of + exactly one; externally changed counts now fail and roll back the process. + +### Fixed + +- Always join both output reader threads when output capture encounters a + reader error or panic. + ## [0.1.0] - 2026-08-02 ### Added diff --git a/docs/adr/0003-attribute-lifetime-model.md b/docs/adr/0003-attribute-lifetime-model.md index aa4709f..262e161 100644 --- a/docs/adr/0003-attribute-lifetime-model.md +++ b/docs/adr/0003-attribute-lifetime-model.md @@ -16,6 +16,11 @@ allocation word alignment and each normal value stable owned storage. Retain both until after `DeleteProcThreadAttributeList`. Pass the borrowed pseudoconsole value according to its Win32 contract. +Derive the native attribute-list pointer from the backing allocation whenever +it is needed. Do not store a second, self-reference-like raw pointer. Snapshot +the `HPCON` value when `SpawnOptions::pseudoconsole` is called while retaining +the original capability lifetime with a private marker. + `SpawnOptions<'a>` carries the lifetime of borrowed Jobs, parent process, and pseudoconsole capabilities; reusable `Command` does not borrow them. @@ -24,4 +29,5 @@ pseudoconsole capabilities; reusable `Command` does not borrow them. - No public raw attribute API or self-referential builder. - Attribute pointers cannot outlive their values. - Delete the list before its values and backing storage. +- Moving `AttributeList` cannot stale a duplicated raw pointer field. - Keep the unsafe lifetime proof inside `sys`. diff --git a/docs/adr/0006-conpty-boundary.md b/docs/adr/0006-conpty-boundary.md index bd31627..6bd8b94 100644 --- a/docs/adr/0006-conpty-boundary.md +++ b/docs/adr/0006-conpty-boundary.md @@ -18,9 +18,15 @@ is public so terminal libraries can implement the bridge. ConPTY creation, pipes, waits, Tokio integration, and lifecycle. windows-spawn owns command lowering, attributes, Jobs, and `CreateProcessW`. +The builder snapshots the `HPCON` numeric value and keeps only a lifetime +marker, so the stored options do not require dynamic dispatch. During process +creation ConPTY has no ordinary standard-handle set: the zero-initialized +startup fields remain zero and `STARTF_USESTDHANDLES` is not set, matching the +Microsoft ConPTY creation sequence. + ## Consequences - Ordinary users pass a safe borrow and do not construct raw `HPCON` values. - windows-spawn does not depend on a terminal library. -- Pseudoconsole use conflicts with explicit standard streams and creates the - process with invalid ordinary standard handles, as required by ConPTY. +- Pseudoconsole use conflicts with explicit standard streams and leaves the + ordinary startup handles unused. diff --git a/docs/adr/0007-spawn-transaction.md b/docs/adr/0007-spawn-transaction.md index 03cb31d..ed29ddc 100644 --- a/docs/adr/0007-spawn-transaction.md +++ b/docs/adr/0007-spawn-transaction.md @@ -15,14 +15,23 @@ distributed cleanup state permits leaks and double closes. Commit moves only the process handle, public pipe endpoints, lifecycle policy, and retained Job ownership into `Child`; thread and temporary handles close. +Private `Running` and `Suspended` marker types parameterize both the plan and +transaction. Each state exposes only its corresponding commit, so a wrong +commit is unrepresentable instead of rejected at runtime. A `HandleTransfer` +owner keeps the effective parent, local or remote duplicates, and inheritance +list together. + `SuspendedChild` represents the suspended state. Its consuming `resume` is the only normal transition to `Child`; dropping it first terminates the process or -its private Job. +its private Job. Resume succeeds only when `ResumeThread` reports the expected +previous suspend count of exactly one. External suspension or resumption makes +the transition fail and the process roll back. ## Consequences - No shared ownership of raw handles. - No public partial-initialization state. +- Running and suspended commits are selected by the type system. - Cleanup follows ownership instead of error-site flags. - Failure-injection and handle-count tests can verify rollback without exposing transaction internals. diff --git a/docs/crate.md b/docs/crate.md index 23ac9b0..6a1f834 100644 --- a/docs/crate.md +++ b/docs/crate.md @@ -79,11 +79,14 @@ pipes close and when terminal EOF occurs. - [`Child::wait_with_output`] drains stdout and stderr concurrently. With `KillTree`, it terminates remaining descendants after the root exits before joining the readers. This guarantees pipe EOF even when a grandchild - inherited a writer. + inherited a writer. Both reader threads are joined even when one reader + fails or panics. - Dropping [`SuspendedChild`] before [`SuspendedChild::resume`] terminates the suspended process. Its ID, process handle, and primary-thread handle are available before resume. `resume(self)` is consuming, so a second transition - is unrepresentable. + is unrepresentable. The transition requires the primary thread's previous + suspend count to be exactly one; external changes are rejected and rolled + back. # Transaction and security boundary @@ -92,6 +95,10 @@ transaction owns pipes, temporary duplicates, attributes, Jobs, and process/thread handles. Success transfers durable resources to [`Child`] or [`SuspendedChild`]; errors roll back the rest. +The private validation plan and transaction carry running or suspended marker +types. Their state-specific commits make a mismatched internal transition +unrepresentable. + This crate is not a sandbox, cross-platform process facade, async runtime, or process supervisor. Tokens, ACLs, `AppContainer`, LPAC, capability SIDs, and async supervision are outside its scope. Callers building an isolation boundary diff --git a/src/child.rs b/src/child.rs index e8f529b..87a9aed 100644 --- a/src/child.rs +++ b/src/child.rs @@ -184,8 +184,7 @@ impl Child { .kill_job .as_ref() .map_or(Ok(()), |job| job.terminate(1)); - let stdout = join_reader(stdout_reader)?; - let stderr = join_reader(stderr_reader)?; + let (stdout, stderr) = join_readers(stdout_reader, stderr_reader)?; termination?; Ok(Output { @@ -222,6 +221,15 @@ fn join_reader(reader: Option>>>) -> io::R } } +fn join_readers( + stdout: Option>>>, + stderr: Option>>>, +) -> io::Result<(Vec, Vec)> { + let stdout = join_reader(stdout); + let stderr = join_reader(stderr); + Ok((stdout?, stderr?)) +} + /// A process whose primary thread has not yet been resumed. /// /// Dropping this value without resuming always terminates the process. @@ -239,14 +247,14 @@ fn join_reader(reader: Option>>>) -> io::R #[must_use = "dropping a suspended child terminates it"] pub struct SuspendedChild { child: Option, - main_thread: Option, + main_thread: OwnedHandle, } impl SuspendedChild { pub(crate) fn new(child: Child, main_thread: OwnedHandle) -> Self { Self { child: Some(child), - main_thread: Some(main_thread), + main_thread, } } @@ -269,16 +277,9 @@ impl SuspendedChild { /// This handle is available for supported thread configuration and /// inspection before [`Self::resume`] consumes the suspended state. /// - /// # Panics - /// - /// Panics only if an internal ownership invariant was violated and the - /// primary thread was removed before this suspended value was consumed. #[must_use] pub fn primary_thread_handle(&self) -> BorrowedHandle<'_> { - self.main_thread - .as_ref() - .expect("a suspended child owns its primary thread until resume") - .as_handle() + self.main_thread.as_handle() } /// Resumes the primary thread and transitions to an ordinary [`Child`]. @@ -286,13 +287,17 @@ impl SuspendedChild { /// # Errors /// /// Returns the operating-system error when the primary thread cannot be - /// resumed. The suspended process is then terminated during rollback. + /// resumed. It also returns `InvalidData` when external suspension or + /// resumption changed the expected suspend count of exactly one. The + /// process is terminated during either rollback. pub fn resume(mut self) -> io::Result { - let main_thread = self - .main_thread - .take() - .ok_or_else(|| io::Error::other("suspended child lost its primary thread"))?; - sys::resume_thread(main_thread.as_handle())?; + let previous = sys::resume_thread(self.main_thread.as_handle())?; + if previous != 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("primary thread suspend count was {previous}, expected 1"), + )); + } self.child .take() .ok_or_else(|| io::Error::other("suspended child lost its process")) @@ -318,6 +323,9 @@ impl Drop for SuspendedChild { #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + use super::*; #[test] @@ -329,4 +337,21 @@ mod tests { io::ErrorKind::Other ); } + + #[test] + fn both_output_readers_are_joined_when_the_first_panics() { + let joined = Arc::new(AtomicBool::new(false)); + let stdout = thread::spawn(|| -> io::Result> { panic!("stdout panic") }); + let stderr_joined = Arc::clone(&joined); + let stderr = thread::spawn(move || { + stderr_joined.store(true, Ordering::Release); + Ok(Vec::new()) + }); + + assert_eq!( + join_readers(Some(stdout), Some(stderr)).unwrap_err().kind(), + io::ErrorKind::Other + ); + assert!(joined.load(Ordering::Acquire)); + } } diff --git a/src/command.rs b/src/command.rs index 4533474..eba50f5 100644 --- a/src/command.rs +++ b/src/command.rs @@ -9,7 +9,7 @@ use std::process::{ExitStatus, Output}; use crate::child::{Child, SuspendedChild}; use crate::handles::Stdio; use crate::options::SpawnOptions; -use crate::plan::{IoMode, SpawnMode, SpawnPlan}; +use crate::plan::{IoMode, SpawnPlan}; use crate::sys; use crate::transaction::SpawnTransaction; @@ -214,8 +214,8 @@ impl Command { /// /// Returns validation, resource-acquisition, or process-creation errors. pub fn spawn_with(&mut self, options: SpawnOptions<'_>) -> io::Result { - let plan = SpawnPlan::new(self, options, SpawnMode::Running, IoMode::Spawn)?; - SpawnTransaction::new(&plan)?.commit_child() + let plan = SpawnPlan::new_running(self, options, IoMode::Spawn)?; + Ok(SpawnTransaction::new(&plan)?.commit_child()) } /// Spawns in the suspended type state with default options. @@ -236,8 +236,8 @@ impl Command { &mut self, options: SpawnOptions<'_>, ) -> io::Result { - let plan = SpawnPlan::new(self, options, SpawnMode::Suspended, IoMode::Spawn)?; - SpawnTransaction::new(&plan)?.commit_suspended() + let plan = SpawnPlan::new_suspended(self, options, IoMode::Spawn)?; + Ok(SpawnTransaction::new(&plan)?.commit_suspended()) } /// Runs the process and waits for its status using default options. @@ -273,9 +273,9 @@ impl Command { /// /// Returns an error from spawning, waiting, reading, or Job termination. pub fn output_with(&mut self, options: SpawnOptions<'_>) -> io::Result { - let plan = SpawnPlan::new(self, options, SpawnMode::Running, IoMode::Output)?; + let plan = SpawnPlan::new_running(self, options, IoMode::Output)?; SpawnTransaction::new(&plan)? - .commit_child()? + .commit_child() .wait_with_output() } } diff --git a/src/lib.rs b/src/lib.rs index c13739e..48dcf77 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,6 @@ mod child; #[cfg(windows)] mod command; #[cfg(windows)] -#[allow(unsafe_code)] mod handles; #[cfg(windows)] mod mitigation; diff --git a/src/options.rs b/src/options.rs index e9bc2c1..166008e 100644 --- a/src/options.rs +++ b/src/options.rs @@ -1,8 +1,15 @@ //! Per-spawn capabilities and creation policy. use std::fmt; +use std::marker::PhantomData; use std::ops::{BitOr, BitOrAssign}; +use windows_sys::Win32::System::Threading::{ + CREATE_BREAKAWAY_FROM_JOB, CREATE_DEFAULT_ERROR_MODE, CREATE_NEW_CONSOLE, + CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_PRESERVE_CODE_AUTHZ_LEVEL, DETACHED_PROCESS, + INHERIT_PARENT_AFFINITY, +}; + use crate::handles::{AsPseudoConsole, Job, ParentProcess}; use crate::mitigation::MitigationPolicy; @@ -31,21 +38,21 @@ impl CreationFlags { } /// Creates a process without inheriting a console. - pub const DETACHED_PROCESS: Self = Self(0x0000_0008); + pub const DETACHED_PROCESS: Self = Self(DETACHED_PROCESS); /// Gives the child a new console. - pub const NEW_CONSOLE: Self = Self(0x0000_0010); + pub const NEW_CONSOLE: Self = Self(CREATE_NEW_CONSOLE); /// Makes the child the root of a new process group. - pub const NEW_PROCESS_GROUP: Self = Self(0x0000_0200); + pub const NEW_PROCESS_GROUP: Self = Self(CREATE_NEW_PROCESS_GROUP); /// Inherits the parent's processor affinity. - pub const INHERIT_PARENT_AFFINITY: Self = Self(0x0001_0000); + pub const INHERIT_PARENT_AFFINITY: Self = Self(INHERIT_PARENT_AFFINITY); /// Allows the child to break away from the caller's Job when permitted. - pub const BREAKAWAY_FROM_JOB: Self = Self(0x0100_0000); + pub const BREAKAWAY_FROM_JOB: Self = Self(CREATE_BREAKAWAY_FROM_JOB); /// Preserves the caller's code-authorization level. - pub const PRESERVE_CODE_AUTHZ_LEVEL: Self = Self(0x0200_0000); + pub const PRESERVE_CODE_AUTHZ_LEVEL: Self = Self(CREATE_PRESERVE_CODE_AUTHZ_LEVEL); /// Prevents the child from inheriting the caller's hard-error mode. - pub const DEFAULT_ERROR_MODE: Self = Self(0x0400_0000); + pub const DEFAULT_ERROR_MODE: Self = Self(CREATE_DEFAULT_ERROR_MODE); /// Runs a console application without creating a console window. - pub const NO_WINDOW: Self = Self(0x0800_0000); + pub const NO_WINDOW: Self = Self(CREATE_NO_WINDOW); pub(crate) const fn bits(self) -> u32 { self.0 @@ -56,6 +63,20 @@ impl CreationFlags { } } +struct PseudoConsole<'a> { + raw: isize, + _borrow: PhantomData<&'a dyn AsPseudoConsole>, +} + +impl<'a> PseudoConsole<'a> { + fn new(pseudoconsole: &'a T) -> Self { + Self { + raw: pseudoconsole.raw_pseudoconsole(), + _borrow: PhantomData, + } + } +} + impl BitOr for CreationFlags { type Output = Self; @@ -92,7 +113,7 @@ pub struct SpawnOptions<'a> { pub(crate) jobs: Vec<&'a Job>, pub(crate) parent: Option<&'a ParentProcess>, pub(crate) mitigation: MitigationPolicy, - pub(crate) pseudoconsole: Option<&'a dyn AsPseudoConsole>, + pseudoconsole: Option>, pub(crate) creation_flags: CreationFlags, pub(crate) drop_policy: DropPolicy, } @@ -104,7 +125,10 @@ impl fmt::Debug for SpawnOptions<'_> { .field("jobs", &self.jobs) .field("parent", &self.parent) .field("mitigation", &self.mitigation) - .field("pseudoconsole", &self.pseudoconsole.map(|_| "borrowed")) + .field( + "pseudoconsole", + &self.pseudoconsole.as_ref().map(|_| "borrowed"), + ) .field("creation_flags", &self.creation_flags) .field("drop_policy", &self.drop_policy) .finish() @@ -155,7 +179,7 @@ impl<'a> SpawnOptions<'a> { /// Attaches the child to a borrowed pseudoconsole. #[must_use] pub fn pseudoconsole(mut self, pseudoconsole: &'a T) -> Self { - self.pseudoconsole = Some(pseudoconsole); + self.pseudoconsole = Some(PseudoConsole::new(pseudoconsole)); self } @@ -172,12 +196,30 @@ impl<'a> SpawnOptions<'a> { self.drop_policy = policy; self } + + pub(crate) fn pseudoconsole_raw(&self) -> Option { + self.pseudoconsole + .as_ref() + .map(|pseudoconsole| pseudoconsole.raw) + } } #[cfg(test)] +#[allow(unsafe_code)] mod tests { + use std::cell::Cell; + use super::*; + struct TestPseudoConsole(Cell); + + // SAFETY: tests use the value only as a snapshot and never pass it to Win32. + unsafe impl AsPseudoConsole for TestPseudoConsole { + fn raw_pseudoconsole(&self) -> isize { + self.0.get() + } + } + #[test] fn creation_flags_combine_idempotently_and_options_keep_job_order() { let combined = CreationFlags::NEW_PROCESS_GROUP | CreationFlags::DEFAULT_ERROR_MODE; @@ -197,4 +239,13 @@ mod tests { assert!(std::ptr::eq(options.jobs[0], &outer)); assert!(std::ptr::eq(options.jobs[1], &inner)); } + + #[test] + fn pseudoconsole_builder_snapshots_the_raw_value() { + let pseudoconsole = TestPseudoConsole(Cell::new(42)); + let options = SpawnOptions::new().pseudoconsole(&pseudoconsole); + pseudoconsole.0.set(99); + assert_eq!(options.pseudoconsole_raw(), Some(42)); + assert!(format!("{options:?}").contains("borrowed")); + } } diff --git a/src/plan.rs b/src/plan.rs index ab82364..e8ceada 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -2,6 +2,7 @@ use std::ffi::OsStr; use std::io; +use std::marker::PhantomData; use std::os::windows::ffi::OsStrExt; use std::path::Path; @@ -9,10 +10,22 @@ use crate::command::{Arg, Command, EnvOp, EnvValue}; use crate::handles::Stdio; use crate::options::{CreationFlags, SpawnOptions}; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum SpawnMode { - Running, - Suspended, +#[derive(Debug)] +pub(crate) struct Running; + +#[derive(Debug)] +pub(crate) struct Suspended; + +pub(crate) trait SpawnState { + const SUSPENDED: bool; +} + +impl SpawnState for Running { + const SUSPENDED: bool = false; +} + +impl SpawnState for Suspended { + const SUSPENDED: bool = true; } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -27,32 +40,58 @@ pub(crate) enum StdioSpec<'a> { Inherit, Null, Piped, - Invalid, } #[derive(Debug)] -pub(crate) struct SpawnPlan<'command, 'options> { +pub(crate) struct StandardHandles { + pub(crate) stdin: T, + pub(crate) stdout: T, + pub(crate) stderr: T, +} + +#[derive(Debug)] +pub(crate) struct SpawnPlan<'command, 'options, M> { pub(crate) command: &'command Command, pub(crate) options: SpawnOptions<'options>, - pub(crate) mode: SpawnMode, - pub(crate) stdin: StdioSpec<'command>, - pub(crate) stdout: StdioSpec<'command>, - pub(crate) stderr: StdioSpec<'command>, + pub(crate) stdio: Option>>, + state: PhantomData, +} + +impl<'command, 'options> SpawnPlan<'command, 'options, Running> { + pub(crate) fn new_running( + command: &'command Command, + options: SpawnOptions<'options>, + io_mode: IoMode, + ) -> io::Result { + Self::build(command, options, io_mode) + } +} + +impl<'command, 'options> SpawnPlan<'command, 'options, Suspended> { + pub(crate) fn new_suspended( + command: &'command Command, + options: SpawnOptions<'options>, + io_mode: IoMode, + ) -> io::Result { + Self::build(command, options, io_mode) + } } -impl<'command, 'options> SpawnPlan<'command, 'options> { - pub(crate) fn new( +impl<'command, 'options, M> SpawnPlan<'command, 'options, M> { + fn build( command: &'command Command, options: SpawnOptions<'options>, - mode: SpawnMode, io_mode: IoMode, ) -> io::Result { validate_command(command)?; - validate_creation_flags(options.creation_flags, options.pseudoconsole.is_some())?; + validate_creation_flags( + options.creation_flags, + options.pseudoconsole_raw().is_some(), + )?; let explicit_stdio = command.stdin.is_some() || command.stdout.is_some() || command.stderr.is_some(); - if options.pseudoconsole.is_some() { + if options.pseudoconsole_raw().is_some() { if explicit_stdio { return Err(invalid( "a pseudoconsole conflicts with explicit standard I/O", @@ -65,37 +104,36 @@ impl<'command, 'options> SpawnPlan<'command, 'options> { } } if options.parent.is_some() - && options.pseudoconsole.is_none() + && options.pseudoconsole_raw().is_none() && (command.stdin.is_none() || command.stdout.is_none() || command.stderr.is_none()) { return Err(invalid( "an alternate parent requires all three standard streams to be explicit", )); } - let (stdin, stdout, stderr) = if options.pseudoconsole.is_some() { - (StdioSpec::Invalid, StdioSpec::Invalid, StdioSpec::Invalid) + let stdio = if options.pseudoconsole_raw().is_some() { + None } else { - match io_mode { - IoMode::Spawn => ( - configured_or(command.stdin.as_ref(), StdioSpec::Inherit), - configured_or(command.stdout.as_ref(), StdioSpec::Inherit), - configured_or(command.stderr.as_ref(), StdioSpec::Inherit), - ), - IoMode::Output => ( - configured_or(command.stdin.as_ref(), StdioSpec::Null), - configured_or(command.stdout.as_ref(), StdioSpec::Piped), - configured_or(command.stderr.as_ref(), StdioSpec::Piped), - ), - } + let handles = match io_mode { + IoMode::Spawn => StandardHandles { + stdin: configured_or(command.stdin.as_ref(), StdioSpec::Inherit), + stdout: configured_or(command.stdout.as_ref(), StdioSpec::Inherit), + stderr: configured_or(command.stderr.as_ref(), StdioSpec::Inherit), + }, + IoMode::Output => StandardHandles { + stdin: configured_or(command.stdin.as_ref(), StdioSpec::Null), + stdout: configured_or(command.stdout.as_ref(), StdioSpec::Piped), + stderr: configured_or(command.stderr.as_ref(), StdioSpec::Piped), + }, + }; + Some(handles) }; Ok(Self { command, options, - mode, - stdin, - stdout, - stderr, + stdio, + state: PhantomData, }) } } @@ -221,26 +259,15 @@ mod tests { fn rejects_batch_and_empty_programs() { let empty = Command::new(""); assert_eq!( - SpawnPlan::new( - &empty, - SpawnOptions::new(), - SpawnMode::Running, - IoMode::Spawn, - ) - .unwrap_err() - .kind(), + SpawnPlan::new_running(&empty, SpawnOptions::new(), IoMode::Spawn,) + .unwrap_err() + .kind(), io::ErrorKind::InvalidInput ); for script in ["thing.cmd", "THING.BAT"] { let command = Command::new(script); - assert!(SpawnPlan::new( - &command, - SpawnOptions::new(), - SpawnMode::Running, - IoMode::Spawn, - ) - .is_err()); + assert!(SpawnPlan::new_running(&command, SpawnOptions::new(), IoMode::Spawn,).is_err()); } } @@ -249,14 +276,14 @@ mod tests { let command = Command::new("cmd.exe"); let options = SpawnOptions::new() .creation_flags(CreationFlags::DETACHED_PROCESS | CreationFlags::NEW_CONSOLE); - assert!(SpawnPlan::new(&command, options, SpawnMode::Running, IoMode::Spawn).is_err()); + assert!(SpawnPlan::new_running(&command, options, IoMode::Spawn).is_err()); for flags in [ CreationFlags::NO_WINDOW | CreationFlags::DETACHED_PROCESS, CreationFlags::NO_WINDOW | CreationFlags::NEW_CONSOLE, ] { let options = SpawnOptions::new().creation_flags(flags); - assert!(SpawnPlan::new(&command, options, SpawnMode::Running, IoMode::Spawn).is_err()); + assert!(SpawnPlan::new_running(&command, options, IoMode::Spawn).is_err()); } } @@ -293,14 +320,9 @@ mod tests { for command in cases { assert_eq!( - SpawnPlan::new( - &command, - SpawnOptions::new(), - SpawnMode::Running, - IoMode::Spawn, - ) - .unwrap_err() - .kind(), + SpawnPlan::new_running(&command, SpawnOptions::new(), IoMode::Spawn,) + .unwrap_err() + .kind(), io::ErrorKind::InvalidInput ); } @@ -312,9 +334,7 @@ mod tests { CreationFlags::NO_WINDOW, ] { let options = SpawnOptions::new().creation_flags(flags); - assert!( - SpawnPlan::new(&valid_command, options, SpawnMode::Running, IoMode::Spawn,).is_ok() - ); + assert!(SpawnPlan::new_running(&valid_command, options, IoMode::Spawn).is_ok()); } } @@ -323,66 +343,73 @@ mod tests { let pseudoconsole = InvalidPseudoConsole; let mut explicit = Command::new("cmd.exe"); explicit.stdin(Stdio::null()); - assert!(SpawnPlan::new( + assert!(SpawnPlan::new_running( &explicit, SpawnOptions::new().pseudoconsole(&pseudoconsole), - SpawnMode::Running, IoMode::Spawn, ) .is_err()); let plain = Command::new("cmd.exe"); - assert!(SpawnPlan::new( + assert!(SpawnPlan::new_running( &plain, SpawnOptions::new().pseudoconsole(&pseudoconsole), - SpawnMode::Running, IoMode::Output, ) .is_err()); - assert!(SpawnPlan::new( + assert!(SpawnPlan::new_running( &plain, SpawnOptions::new() .pseudoconsole(&pseudoconsole) .creation_flags(CreationFlags::NEW_CONSOLE), - SpawnMode::Running, IoMode::Spawn, ) .is_err()); let parent = ParentProcess::open(std::process::id()).unwrap(); - assert!(SpawnPlan::new( + assert!(SpawnPlan::new_running( &plain, SpawnOptions::new().parent_process(&parent), - SpawnMode::Running, IoMode::Spawn, ) .is_err()); + + for missing in 0..3 { + let mut command = Command::new("cmd.exe"); + if missing != 0 { + command.stdin(Stdio::null()); + } + if missing != 1 { + command.stdout(Stdio::null()); + } + if missing != 2 { + command.stderr(Stdio::null()); + } + assert!(SpawnPlan::new_running( + &command, + SpawnOptions::new().parent_process(&parent), + IoMode::Spawn, + ) + .is_err()); + } } #[test] fn successful_plans_choose_the_expected_stdio_modes() { let command = Command::new("cmd.exe"); - let output = SpawnPlan::new( - &command, - SpawnOptions::new(), - SpawnMode::Running, - IoMode::Output, - ) - .unwrap(); - assert!(matches!(output.stdin, StdioSpec::Null)); - assert!(matches!(output.stdout, StdioSpec::Piped)); - assert!(matches!(output.stderr, StdioSpec::Piped)); + let output = SpawnPlan::new_running(&command, SpawnOptions::new(), IoMode::Output).unwrap(); + let output_stdio = output.stdio.unwrap(); + assert!(matches!(output_stdio.stdin, StdioSpec::Null)); + assert!(matches!(output_stdio.stdout, StdioSpec::Piped)); + assert!(matches!(output_stdio.stderr, StdioSpec::Piped)); let pseudoconsole = InvalidPseudoConsole; - let pcon = SpawnPlan::new( + let pcon = SpawnPlan::new_suspended( &command, SpawnOptions::new().pseudoconsole(&pseudoconsole), - SpawnMode::Suspended, IoMode::Spawn, ) .unwrap(); - assert!(matches!(pcon.stdin, StdioSpec::Invalid)); - assert!(matches!(pcon.stdout, StdioSpec::Invalid)); - assert!(matches!(pcon.stderr, StdioSpec::Invalid)); + assert!(pcon.stdio.is_none()); } } diff --git a/src/sys.rs b/src/sys.rs index b41e85d..9038ac4 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -235,8 +235,14 @@ pub(crate) fn create_pipe(parent_reads: bool) -> io::Result { if unsafe { CreatePipe(&mut read, &mut write, ptr::null::(), 0) } == 0 { return Err(io::Error::last_os_error()); } - let read = owned(read)?; - let write = owned(write)?; + // SAFETY: successful CreatePipe guarantees two valid, distinct handles; + // ownership of both is transferred together before either can be lost. + let (read, write) = unsafe { + ( + OwnedHandle::from_raw_handle(read as RawHandle), + OwnedHandle::from_raw_handle(write as RawHandle), + ) + }; if parent_reads { Ok(Pipe { parent: read, @@ -334,7 +340,6 @@ pub(crate) fn terminate_job(job: BorrowedHandle<'_>, exit_code: u32) -> io::Resu pub(crate) struct AttributeList { storage: Box<[usize]>, - pointer: LPPROC_THREAD_ATTRIBUTE_LIST, } impl AttributeList { @@ -369,7 +374,7 @@ impl AttributeList { if unsafe { InitializeProcThreadAttributeList(pointer, count, 0, &mut actual) } == 0 { return Err(io::Error::last_os_error()); } - Ok(Self { storage, pointer }) + Ok(Self { storage }) } pub(crate) fn set_handle_list(&mut self, handles: &[isize]) -> io::Result<()> { @@ -420,7 +425,7 @@ impl AttributeList { // every backing allocation stable through CreateProcessW. if unsafe { UpdateProcThreadAttribute( - self.pointer, + self.pointer(), 0, attribute, value, @@ -437,7 +442,7 @@ impl AttributeList { } fn pointer(&self) -> LPPROC_THREAD_ATTRIBUTE_LIST { - self.pointer + self.storage.as_ptr().cast_mut().cast() } } @@ -446,19 +451,23 @@ impl Drop for AttributeList { #[cfg(test)] ATTRIBUTE_LIST_DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); // SAFETY: initialization succeeded once and this is its sole owner. - unsafe { DeleteProcThreadAttributeList(self.pointer) }; - let _ = self.storage.len(); + unsafe { DeleteProcThreadAttributeList(self.pointer()) }; } } +#[derive(Clone, Copy)] +pub(crate) struct StandardHandles { + pub(crate) stdin: isize, + pub(crate) stdout: isize, + pub(crate) stderr: isize, +} + pub(crate) struct ProcessRequest<'a> { pub(crate) application: &'a [u16], pub(crate) command_line: &'a mut [u16], pub(crate) environment: Option<&'a [u16]>, pub(crate) current_dir: Option<&'a [u16]>, - pub(crate) stdin: isize, - pub(crate) stdout: isize, - pub(crate) stderr: isize, + pub(crate) stdio: Option, pub(crate) inherit_handles: bool, pub(crate) creation_flags: u32, pub(crate) suspended: bool, @@ -479,10 +488,7 @@ pub(crate) fn create_process(request: &mut ProcessRequest<'_>) -> io::Result()) .expect("startup structure size fits u32") }; - startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; - startup.StartupInfo.hStdInput = request.stdin as HANDLE; - startup.StartupInfo.hStdOutput = request.stdout as HANDLE; - startup.StartupInfo.hStdError = request.stderr as HANDLE; + set_standard_handles(&mut startup, request.stdio); startup.lpAttributeList = request .attributes .map_or(ptr::null_mut(), AttributeList::pointer); @@ -521,13 +527,30 @@ pub(crate) fn create_process(request: &mut ProcessRequest<'_>) -> io::Result) { + if let Some(handles) = handles { + startup.StartupInfo.dwFlags |= STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = handles.stdin as HANDLE; + startup.StartupInfo.hStdOutput = handles.stdout as HANDLE; + startup.StartupInfo.hStdError = handles.stderr as HANDLE; + } +} + pub(crate) fn wait_process(process: BorrowedHandle<'_>) -> io::Result<()> { // SAFETY: the process handle remains valid while waiting. match unsafe { WaitForSingleObject(raw(process), INFINITE) } { @@ -584,12 +607,13 @@ pub(crate) fn terminate_process(process: BorrowedHandle<'_>, exit_code: u32) -> bool_result(unsafe { TerminateProcess(raw(process), exit_code) }) } -pub(crate) fn resume_thread(thread: BorrowedHandle<'_>) -> io::Result<()> { +pub(crate) fn resume_thread(thread: BorrowedHandle<'_>) -> io::Result { // SAFETY: the thread handle remains valid for the call. - if unsafe { ResumeThread(raw(thread)) } == u32::MAX { + let previous = unsafe { ResumeThread(raw(thread)) }; + if previous == u32::MAX { Err(io::Error::last_os_error()) } else { - Ok(()) + Ok(previous) } } @@ -896,6 +920,30 @@ mod tests { Ok(()) } + #[test] + fn startup_info_uses_standard_handles_only_when_supplied() { + let mut conpty = STARTUPINFOEXW::default(); + set_standard_handles(&mut conpty, None); + assert_eq!(conpty.StartupInfo.dwFlags & STARTF_USESTDHANDLES, 0); + assert!(conpty.StartupInfo.hStdInput.is_null()); + assert!(conpty.StartupInfo.hStdOutput.is_null()); + assert!(conpty.StartupInfo.hStdError.is_null()); + + let mut ordinary = STARTUPINFOEXW::default(); + set_standard_handles( + &mut ordinary, + Some(StandardHandles { + stdin: 1, + stdout: 2, + stderr: 3, + }), + ); + assert_ne!(ordinary.StartupInfo.dwFlags & STARTF_USESTDHANDLES, 0); + assert_eq!(ordinary.StartupInfo.hStdInput as isize, 1); + assert_eq!(ordinary.StartupInfo.hStdOutput as isize, 2); + assert_eq!(ordinary.StartupInfo.hStdError as isize, 3); + } + #[test] fn environment_paths_comparison_and_error_helpers_work() -> io::Result<()> { let drops_before = ENVIRONMENT_BLOCK_DROPS.load(std::sync::atomic::Ordering::Relaxed); diff --git a/src/transaction.rs b/src/transaction.rs index c868a0b..7b976fd 100644 --- a/src/transaction.rs +++ b/src/transaction.rs @@ -6,6 +6,7 @@ use std::env; use std::ffi::{OsStr, OsString}; use std::io; use std::iter; +use std::marker::PhantomData; use std::os::windows::ffi::OsStrExt; use std::os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle, OwnedHandle}; use std::path::{Path, PathBuf}; @@ -14,7 +15,7 @@ use crate::child::{Child, SuspendedChild}; use crate::command::{Arg, Command, EnvOp, EnvValue}; use crate::handles::{Job, StdioInner}; use crate::options::DropPolicy; -use crate::plan::{SpawnMode, SpawnPlan, StdioSpec}; +use crate::plan::{Running, SpawnPlan, SpawnState, StandardHandles, StdioSpec, Suspended}; use crate::sys::{self, NullAccess, StandardStream}; const QUOTE: u16 = 0x22; @@ -23,74 +24,44 @@ const SPACE: u16 = 0x20; /// Owns the process between `CreateProcessW` success and an explicit commit. /// Dropping before commit terminates the partially-created process. -pub(crate) struct SpawnTransaction { +pub(crate) struct SpawnTransaction { created: Option, kill_job: Option, - stdin: Option, - stdout: Option, - stderr: Option, - suspended: bool, + stdio: StandardHandles>, + state: PhantomData, } -impl SpawnTransaction { +impl SpawnTransaction { #[allow(clippy::too_many_lines)] pub(crate) fn new<'command, 'options>( - plan: &SpawnPlan<'command, 'options>, + plan: &SpawnPlan<'command, 'options, M>, ) -> io::Result { let parent: Option> = plan.options.parent.map(AsHandle::as_handle); - let mut local_inheritable = Vec::new(); - let mut remote_inheritable = Vec::new(); - let mut inherited_values = Vec::new(); - - let (stdin_value, stdin) = prepare_stdio( - plan.stdin, - StandardStream::Input, - parent, - &mut local_inheritable, - &mut remote_inheritable, - &mut inherited_values, - )?; - let (stdout_value, stdout) = prepare_stdio( - plan.stdout, - StandardStream::Output, - parent, - &mut local_inheritable, - &mut remote_inheritable, - &mut inherited_values, - )?; - let (stderr_value, stderr) = prepare_stdio( - plan.stderr, - StandardStream::Error, - parent, - &mut local_inheritable, - &mut remote_inheritable, - &mut inherited_values, - )?; - - let mut argument_handles = Vec::new(); - let mut environment_handles = Vec::new(); - for argument in &plan.command.args { - if let Arg::Handle(handle) = argument { - argument_handles.push(transfer_handle( - handle.as_handle(), - parent, - &mut local_inheritable, - &mut remote_inheritable, - &mut inherited_values, - )?); - } - } - for operation in &plan.command.env_ops { - if let EnvOp::Set(_, EnvValue::Handle(handle)) = operation { - environment_handles.push(transfer_handle( - handle.as_handle(), - parent, - &mut local_inheritable, - &mut remote_inheritable, - &mut inherited_values, - )?); + let mut transfer = HandleTransfer::new(parent); + let (stdio_values, stdio) = match &plan.stdio { + Some(specs) => { + let prepared = prepare_standard_handles(specs, &mut transfer)?; + let values = sys::StandardHandles { + stdin: prepared.stdin.child, + stdout: prepared.stdout.child, + stderr: prepared.stderr.child, + }; + let owners = StandardHandles { + stdin: prepared.stdin.parent, + stdout: prepared.stdout.parent, + stderr: prepared.stderr.parent, + }; + (Some(values), owners) } - } + None => ( + None, + StandardHandles { + stdin: None, + stdout: None, + stderr: None, + }, + ), + }; let kill_job = if plan.options.drop_policy == DropPolicy::KillTree { let job = Job::create()?; job.set_kill_on_close(true)?; @@ -108,8 +79,8 @@ impl SpawnTransaction { job_values.push(job.as_handle().as_raw_handle() as isize); } - let command_line = build_command_line(plan.command, &argument_handles); - let environment = build_environment(plan.command, &environment_handles)?; + let command_line = build_command_line(plan.command, &mut transfer)?; + let environment = build_environment(plan.command, &mut transfer)?; let child_path = environment.path.as_deref(); let application = resolve_executable(&plan.command.program, child_path)?; let current_dir = plan @@ -120,15 +91,14 @@ impl SpawnTransaction { .transpose()?; // Freeze every pointer-valued attribute before adding it to the list. - let inherited_values = inherited_values.into_boxed_slice(); - let parent_value = parent.map(|handle| Box::new(handle.as_raw_handle() as isize)); + let inherited_values = transfer.inherited_values().to_vec().into_boxed_slice(); + let parent_value = transfer + .parent() + .map(|handle| Box::new(handle.as_raw_handle() as isize)); let mitigation_words = plan.options.mitigation.words(); let mitigation_value = (mitigation_words != [0, 0]).then(|| Box::new(mitigation_words)); let job_values = job_values.into_boxed_slice(); - let pseudoconsole = plan - .options - .pseudoconsole - .map(crate::handles::AsPseudoConsole::raw_pseudoconsole); + let pseudoconsole = plan.options.pseudoconsole_raw(); let attribute_count = u32::from(!inherited_values.is_empty()) + u32::from(parent_value.is_some()) @@ -164,12 +134,10 @@ impl SpawnTransaction { command_line: &mut command_line, environment: environment.block.as_deref(), current_dir: current_dir.as_deref(), - stdin: stdin_value, - stdout: stdout_value, - stderr: stderr_value, + stdio: stdio_values, inherit_handles: !inherited_values.is_empty(), creation_flags: plan.options.creation_flags.bits(), - suspended: plan.mode == SpawnMode::Suspended, + suspended: M::SUSPENDED, attributes: attributes.as_ref(), }; let created = sys::create_process(&mut request)?; @@ -177,48 +145,17 @@ impl SpawnTransaction { // Attribute backing, local inheritable duplicates, and alternate-parent // remote sources all roll back here. The child now owns inherited copies. drop(attributes); - drop(remote_inheritable); - drop(local_inheritable); + drop(transfer); Ok(Self { created: Some(created), kill_job, - stdin, - stdout, - stderr, - suspended: plan.mode == SpawnMode::Suspended, + stdio, + state: PhantomData, }) } - pub(crate) fn commit_child(mut self) -> io::Result { - if self.suspended { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "a suspended transaction must commit as SuspendedChild", - )); - } - let created = self - .created - .take() - .expect("an uncommitted transaction owns its process"); - drop(created.thread); - Ok(Child::new( - created.process, - created.pid, - self.kill_job.take(), - self.stdin.take(), - self.stdout.take(), - self.stderr.take(), - )) - } - - pub(crate) fn commit_suspended(mut self) -> io::Result { - if !self.suspended { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "a running transaction cannot commit as SuspendedChild", - )); - } + fn commit_parts(mut self) -> (Child, OwnedHandle) { let created = self .created .take() @@ -227,15 +164,30 @@ impl SpawnTransaction { created.process, created.pid, self.kill_job.take(), - self.stdin.take(), - self.stdout.take(), - self.stderr.take(), + self.stdio.stdin.take(), + self.stdio.stdout.take(), + self.stdio.stderr.take(), ); - Ok(SuspendedChild::new(child, created.thread)) + (child, created.thread) } } -impl Drop for SpawnTransaction { +impl SpawnTransaction { + pub(crate) fn commit_child(self) -> Child { + let (child, thread) = self.commit_parts(); + drop(thread); + child + } +} + +impl SpawnTransaction { + pub(crate) fn commit_suspended(self) -> SuspendedChild { + let (child, thread) = self.commit_parts(); + SuspendedChild::new(child, thread) + } +} + +impl Drop for SpawnTransaction { fn drop(&mut self) { if let Some(created) = &self.created { let _ = sys::terminate_process(created.process.as_handle(), 1); @@ -244,102 +196,127 @@ impl Drop for SpawnTransaction { } } -fn prepare_stdio<'a>( +struct PreparedStdio { + child: isize, + parent: Option, +} + +fn prepare_standard_handles( + specs: &StandardHandles>, + transfer: &mut HandleTransfer<'_>, +) -> io::Result> { + Ok(StandardHandles { + stdin: prepare_stdio(specs.stdin, StandardStream::Input, transfer)?, + stdout: prepare_stdio(specs.stdout, StandardStream::Output, transfer)?, + stderr: prepare_stdio(specs.stderr, StandardStream::Error, transfer)?, + }) +} + +fn prepare_stdio( spec: StdioSpec<'_>, stream: StandardStream, - parent: Option>, - local: &mut Vec, - remote: &mut Vec>, - inherited: &mut Vec, -) -> io::Result<(isize, Option)> { + transfer: &mut HandleTransfer<'_>, +) -> io::Result { match spec { - StdioSpec::Invalid => Ok((sys::INVALID_RAW_HANDLE, None)), - StdioSpec::Inherit => prepare_inherit(stream, parent, local, remote, inherited), - StdioSpec::Null => prepare_null(stream, parent, local, remote, inherited), - StdioSpec::Piped => prepare_pipe(stream, parent, local, remote, inherited), + StdioSpec::Inherit => prepare_inherit(stream, transfer), + StdioSpec::Null => prepare_null(stream, transfer), + StdioSpec::Piped => prepare_pipe(stream, transfer), StdioSpec::Configured(stdio) => match &stdio.inner { - StdioInner::Inherit => prepare_inherit(stream, parent, local, remote, inherited), - StdioInner::Null => prepare_null(stream, parent, local, remote, inherited), - StdioInner::Piped => prepare_pipe(stream, parent, local, remote, inherited), - StdioInner::Owned(handle) => Ok(( - transfer_handle(handle.as_handle(), parent, local, remote, inherited)?, - None, - )), + StdioInner::Inherit => prepare_inherit(stream, transfer), + StdioInner::Null => prepare_null(stream, transfer), + StdioInner::Piped => prepare_pipe(stream, transfer), + StdioInner::Owned(handle) => Ok(PreparedStdio { + child: transfer.lower(handle.as_handle())?, + parent: None, + }), }, } } -fn prepare_inherit<'a>( +fn prepare_inherit( stream: StandardStream, - parent: Option>, - local: &mut Vec, - remote: &mut Vec>, - inherited: &mut Vec, -) -> io::Result<(isize, Option)> { + transfer: &mut HandleTransfer<'_>, +) -> io::Result { match sys::standard_handle(stream)? { - Some(handle) => Ok(( - transfer_handle(handle.as_handle(), parent, local, remote, inherited)?, - None, - )), - None => Ok((sys::INVALID_RAW_HANDLE, None)), + Some(handle) => Ok(PreparedStdio { + child: transfer.lower(handle.as_handle())?, + parent: None, + }), + None => Ok(PreparedStdio { + child: sys::INVALID_RAW_HANDLE, + parent: None, + }), } } -fn prepare_null<'a>( +fn prepare_null( stream: StandardStream, - parent: Option>, - local: &mut Vec, - remote: &mut Vec>, - inherited: &mut Vec, -) -> io::Result<(isize, Option)> { + transfer: &mut HandleTransfer<'_>, +) -> io::Result { let access = match stream { StandardStream::Input => NullAccess::Read, StandardStream::Output | StandardStream::Error => NullAccess::Write, }; let handle = sys::null_handle(access)?; - Ok(( - transfer_handle(handle.as_handle(), parent, local, remote, inherited)?, - None, - )) + Ok(PreparedStdio { + child: transfer.lower(handle.as_handle())?, + parent: None, + }) } -fn prepare_pipe<'a>( +fn prepare_pipe( stream: StandardStream, - parent: Option>, - local: &mut Vec, - remote: &mut Vec>, - inherited: &mut Vec, -) -> io::Result<(isize, Option)> { + transfer: &mut HandleTransfer<'_>, +) -> io::Result { let pipe = sys::create_pipe(!matches!(stream, StandardStream::Input))?; - let value = transfer_handle(pipe.child.as_handle(), parent, local, remote, inherited)?; - Ok((value, Some(pipe.parent))) + let child = transfer.lower(pipe.child.as_handle())?; + Ok(PreparedStdio { + child, + parent: Some(pipe.parent), + }) } -fn transfer_handle<'a>( - source: BorrowedHandle<'_>, +struct HandleTransfer<'a> { parent: Option>, - local: &mut Vec, - remote: &mut Vec>, - inherited: &mut Vec, -) -> io::Result { - let value = if let Some(parent) = parent { - let handle = sys::duplicate_remote(source, parent, true)?; - let value = handle.value(); - remote.push(handle); - value - } else { - let handle = sys::duplicate_local(source, true)?; - let value = handle.as_raw_handle() as isize; - local.push(handle); - value - }; - push_unique(inherited, value); - Ok(value) + local: Vec, + remote: Vec>, + inherited: Vec, } -fn push_unique(values: &mut Vec, value: isize) { - if !values.contains(&value) { - values.push(value); +impl<'a> HandleTransfer<'a> { + fn new(parent: Option>) -> Self { + Self { + parent, + local: Vec::new(), + remote: Vec::new(), + inherited: Vec::new(), + } + } + + fn lower(&mut self, source: BorrowedHandle<'_>) -> io::Result { + let value = if let Some(parent) = self.parent { + let handle = sys::duplicate_remote(source, parent, true)?; + let value = handle.value(); + self.remote.push(handle); + value + } else { + let handle = sys::duplicate_local(source, true)?; + let value = handle.as_raw_handle() as isize; + self.local.push(handle); + value + }; + if !self.inherited.contains(&value) { + self.inherited.push(value); + } + Ok(value) + } + + fn parent(&self) -> Option> { + self.parent + } + + fn inherited_values(&self) -> &[isize] { + &self.inherited } } @@ -381,7 +358,10 @@ impl PartialEq for EnvKey { impl Eq for EnvKey {} -fn build_environment(command: &Command, handle_values: &[isize]) -> io::Result { +fn build_environment( + command: &Command, + transfer: &mut HandleTransfer<'_>, +) -> io::Result { if !command.env_clear && command.env_ops.is_empty() { return Ok(Environment { block: None, @@ -395,18 +375,14 @@ fn build_environment(command: &Command, handle_values: &[isize]) -> io::Result { let value = match value { EnvValue::Text(value) => value.clone(), - EnvValue::Handle(_) => OsString::from( - handles - .next() - .expect("one lowered value exists per handle environment op") - .to_string(), - ), + EnvValue::Handle(handle) => { + OsString::from(transfer.lower(handle.as_handle())?.to_string()) + } }; match map.entry(EnvKey::new(key.clone())) { Entry::Occupied(mut entry) => { @@ -441,30 +417,27 @@ fn build_environment(command: &Command, handle_values: &[isize]) -> io::Result Vec { +fn build_command_line( + command: &Command, + transfer: &mut HandleTransfer<'_>, +) -> io::Result> { let mut result = Vec::new(); result.push(QUOTE); result.extend(command.program.encode_wide()); result.push(QUOTE); - let mut handles = handle_values.iter(); for argument in &command.args { result.push(SPACE); match argument { Arg::Text(text) => append_regular_arg(&mut result, text), Arg::Raw(text) => result.extend(text.encode_wide()), - Arg::Handle(_) => { - let text = OsString::from( - handles - .next() - .expect("one lowered value exists per handle argument") - .to_string(), - ); + Arg::Handle(handle) => { + let text = OsString::from(transfer.lower(handle.as_handle())?.to_string()); append_regular_arg(&mut result, &text); } } } result.push(0); - result + Ok(result) } fn append_regular_arg(command: &mut Vec, argument: &OsStr) { @@ -612,7 +585,8 @@ mod tests { fn quotes_regular_and_preserves_raw_arguments() { let mut command = Command::new("program.exe"); command.arg("a b").arg("a\"b").raw_arg("x&&y"); - let line = build_command_line(&command, &[]); + let mut transfer = HandleTransfer::new(None); + let line = build_command_line(&command, &mut transfer).unwrap(); assert_eq!(decode(&line), r#""program.exe" "a b" "a\"b" x&&y"#); } @@ -626,7 +600,8 @@ mod tests { fn cleared_environment_is_double_nul() { let mut command = Command::new("cmd.exe"); command.env_clear(); - let environment = build_environment(&command, &[]).unwrap(); + let mut transfer = HandleTransfer::new(None); + let environment = build_environment(&command, &mut transfer).unwrap(); assert_eq!(environment.block.unwrap(), vec![0, 0]); } @@ -638,7 +613,8 @@ mod tests { .env("PATH", "second") .env("REMOVE_ME", "value") .env_remove("remove_me"); - let environment = build_environment(&command, &[]).unwrap(); + let mut transfer = HandleTransfer::new(None); + let environment = build_environment(&command, &mut transfer).unwrap(); assert_eq!(environment.path, Some(OsString::from("second"))); let block = environment.block.unwrap(); let text = String::from_utf16_lossy(&block); @@ -656,7 +632,8 @@ mod tests { fn quoting_covers_empty_and_trailing_backslashes() { let mut command = Command::new("program.exe"); command.arg("").arg(r"C:\path with spaces\"); - let line = decode(&build_command_line(&command, &[])); + let mut transfer = HandleTransfer::new(None); + let line = decode(&build_command_line(&command, &mut transfer).unwrap()); assert_eq!(line, r#""program.exe" "" "C:\path with spaces\\""#); } @@ -705,20 +682,19 @@ mod tests { } #[test] - fn wrong_transaction_commit_rolls_the_process_back() { - let mut suspended_command = Command::new("cmd.exe"); - suspended_command.args(["/D", "/C", "ping -n 10 127.0.0.1 >nul"]); - let suspended = SpawnPlan::new( - &suspended_command, + fn uncommitted_running_and_suspended_transactions_roll_back() { + let mut running_command = Command::new("cmd.exe"); + running_command.args(["/D", "/C", "ping -n 10 127.0.0.1 >nul"]); + let running = SpawnPlan::new_running( + &running_command, crate::SpawnOptions::new(), - SpawnMode::Suspended, crate::plan::IoMode::Spawn, ) .unwrap(); - let suspended_transaction = SpawnTransaction::new(&suspended).unwrap(); - let mut suspended_process = ProcessExitGuard::new( + let running_transaction = SpawnTransaction::new(&running).unwrap(); + let mut running_process = ProcessExitGuard::new( sys::duplicate_local( - suspended_transaction + running_transaction .created .as_ref() .unwrap() @@ -728,25 +704,21 @@ mod tests { ) .unwrap(), ); - assert_eq!( - suspended_transaction.commit_child().unwrap_err().kind(), - io::ErrorKind::InvalidInput - ); - suspended_process.assert_exited("rollback did not terminate its suspended process"); + drop(running_transaction); + running_process.assert_exited("rollback did not terminate its running process"); - let mut running_command = Command::new("cmd.exe"); - running_command.args(["/D", "/C", "ping -n 10 127.0.0.1 >nul"]); - let running = SpawnPlan::new( - &running_command, + let mut suspended_command = Command::new("cmd.exe"); + suspended_command.args(["/D", "/C", "ping -n 10 127.0.0.1 >nul"]); + let suspended = SpawnPlan::new_suspended( + &suspended_command, crate::SpawnOptions::new(), - SpawnMode::Running, crate::plan::IoMode::Spawn, ) .unwrap(); - let running_transaction = SpawnTransaction::new(&running).unwrap(); - let mut running_process = ProcessExitGuard::new( + let suspended_transaction = SpawnTransaction::new(&suspended).unwrap(); + let mut suspended_process = ProcessExitGuard::new( sys::duplicate_local( - running_transaction + suspended_transaction .created .as_ref() .unwrap() @@ -756,21 +728,17 @@ mod tests { ) .unwrap(), ); - assert_eq!( - running_transaction.commit_suspended().unwrap_err().kind(), - io::ErrorKind::InvalidInput - ); - running_process.assert_exited("rollback did not terminate its running process"); + drop(suspended_transaction); + suspended_process.assert_exited("rollback did not terminate its suspended process"); } #[test] fn suspended_child_drop_terminates_the_process() { let mut command = Command::new("cmd.exe"); command.args(["/D", "/C", "exit /b 0"]); - let plan = SpawnPlan::new( + let plan = SpawnPlan::new_suspended( &command, crate::SpawnOptions::new(), - SpawnMode::Suspended, crate::plan::IoMode::Spawn, ) .unwrap(); @@ -782,7 +750,7 @@ mod tests { ) .unwrap(), ); - drop(transaction.commit_suspended().unwrap()); + drop(transaction.commit_suspended()); process.assert_exited("dropping SuspendedChild did not terminate the process"); } } diff --git a/tests/windows_spawn.rs b/tests/windows_spawn.rs index f74aa33..1704287 100644 --- a/tests/windows_spawn.rs +++ b/tests/windows_spawn.rs @@ -24,13 +24,14 @@ use windows_sys::Win32::Storage::FileSystem::{ FILE_TYPE_UNKNOWN, }; use windows_sys::Win32::System::Console::{ - ClosePseudoConsole, CreatePseudoConsole, GetConsoleMode, GetStdHandle, COORD, HPCON, + ClosePseudoConsole, CreatePseudoConsole, GetConsoleCP, GetStdHandle, COORD, HPCON, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, }; -use windows_sys::Win32::System::Pipes::CreatePipe; +use windows_sys::Win32::System::Pipes::{CreatePipe, PeekNamedPipe}; use windows_sys::Win32::System::Threading::{ GetCurrentProcess, GetProcessHandleCount, GetProcessId, GetProcessMitigationPolicy, - GetThreadId, ProcessExtensionPointDisablePolicy, TerminateProcess, WaitForSingleObject, + GetThreadId, ProcessExtensionPointDisablePolicy, SuspendThread, TerminateProcess, + WaitForSingleObject, }; fn cmd(script: &str) -> Command { @@ -214,6 +215,26 @@ fn suspended_child_resumes_once_into_normal_state() -> io::Result<()> { Ok(()) } +#[test] +fn resume_rejects_an_externally_changed_suspend_count() -> io::Result<()> { + let mut command = cmd("ping -n 10 127.0.0.1 >nul"); + let suspended = command.spawn_suspended()?; + let mut process = ProcessExitGuard::new(local_duplicate(&suspended, false)?); + // SAFETY: the primary thread handle remains owned by SuspendedChild and + // has THREAD_SUSPEND_RESUME access from CreateProcessW. + assert_eq!( + unsafe { SuspendThread(suspended.primary_thread_handle().as_raw_handle()) }, + 1 + ); + + assert_eq!( + suspended.resume().unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + assert!(process.wait(Duration::from_secs(5))?); + Ok(()) +} + #[test] fn inherited_and_null_standard_handles_are_usable() -> io::Result<()> { let test_binary = std::env::current_exe()?; @@ -639,6 +660,62 @@ impl TestPseudoConsole { output_reader: Some(output_reader), }) } + + fn wait_for_output(&self, expected: &[u8]) -> io::Result { + let output = self + .output_reader + .as_ref() + .expect("a live pseudoconsole retains its output reader"); + let deadline = Instant::now() + Duration::from_secs(5); + let mut received = Vec::new(); + loop { + let mut available = 0_u32; + // SAFETY: the pipe handle remains owned by self, available is + // writable, and the unused optional output pointers are null. + if unsafe { + PeekNamedPipe( + output.as_raw_handle(), + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut available, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + if available != 0 { + let mut buffer = vec![0_u8; available as usize]; + let mut read = 0_u32; + // SAFETY: buffer is writable for its length, read is writable, + // and the owned synchronous pipe handle remains valid. + if unsafe { + ReadFile( + output.as_raw_handle(), + buffer.as_mut_ptr(), + available, + &mut read, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + received.extend_from_slice(&buffer[..read as usize]); + if received + .windows(expected.len()) + .any(|window| window == expected) + { + return Ok(true); + } + } + if Instant::now() >= deadline { + return Ok(false); + } + thread::sleep(Duration::from_millis(10)); + } + } } impl Drop for TestPseudoConsole { @@ -697,6 +774,7 @@ fn pseudoconsole_attribute_connects_the_child_console() -> io::Result<()> { wait_bounded(&mut child)?.expect("ConPTY child must terminate after kill") }; assert!(status.success()); + assert!(pseudoconsole.wait_for_output(b"windows-spawn-pcon-attached")?); Ok(()) } @@ -705,12 +783,12 @@ fn pseudoconsole_child_probe() { if std::env::var_os("WINDOWS_SPAWN_PCON_PROBE").is_none() { return; } - // SAFETY: the process owns its standard-output slot and mode is writable. - let output = unsafe { GetStdHandle(STD_OUTPUT_HANDLE) }; - let mut mode = 0_u32; - assert!(!output.is_null() && output != INVALID_HANDLE_VALUE); - // SAFETY: a valid standard-output handle and DWORD output are supplied. - assert_ne!(unsafe { GetConsoleMode(output, &mut mode) }, 0); + // SAFETY: GetConsoleCP has no pointer preconditions. A nonzero code page + // proves this process was attached to a console even though ConPTY startup + // intentionally leaves all ordinary standard-handle slots zero. + assert_ne!(unsafe { GetConsoleCP() }, 0); + let mut output = File::options().write(true).open("CONOUT$").unwrap(); + output.write_all(b"windows-spawn-pcon-attached").unwrap(); } #[test] diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 267a3ff..ae08b1f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -4,14 +4,13 @@ mod cli; mod tasks; use std::env; -use std::io; use std::process; fn main() { let arguments = env::args().skip(1); let result = match cli::parse(arguments) { Ok(task) => tasks::execute(task), - Err(error) => Err(io::Error::new(io::ErrorKind::InvalidInput, error).into()), + Err(error) => Err(tasks::TaskError::from(error)), }; match result { Ok(code) => process::exit(code), diff --git a/xtask/src/tasks.rs b/xtask/src/tasks.rs index f6ea404..c395065 100644 --- a/xtask/src/tasks.rs +++ b/xtask/src/tasks.rs @@ -5,13 +5,87 @@ use sha2::{Digest, Sha256}; use std::env; use std::error::Error; use std::ffi::{OsStr, OsString}; +use std::fmt; use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Write}; use std::path::{Component, Path, PathBuf}; use std::process::{Command, Output}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::string::FromUtf8Error; +use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH}; -pub(crate) type Result = std::result::Result>; +pub(crate) type Result = std::result::Result; + +#[derive(Debug)] +pub(crate) enum TaskError { + Io(io::Error), + Json(serde_json::Error), + Utf8(FromUtf8Error), + Semver(semver::Error), + SystemTime(SystemTimeError), + Message(String), +} + +impl fmt::Display for TaskError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => error.fmt(formatter), + Self::Json(error) => error.fmt(formatter), + Self::Utf8(error) => error.fmt(formatter), + Self::Semver(error) => error.fmt(formatter), + Self::SystemTime(error) => error.fmt(formatter), + Self::Message(message) => formatter.write_str(message), + } + } +} + +impl Error for TaskError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::Json(error) => Some(error), + Self::Utf8(error) => Some(error), + Self::Semver(error) => Some(error), + Self::SystemTime(error) => Some(error), + Self::Message(_) => None, + } + } +} + +impl From for TaskError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl From for TaskError { + fn from(error: serde_json::Error) -> Self { + Self::Json(error) + } +} + +impl From for TaskError { + fn from(error: FromUtf8Error) -> Self { + Self::Utf8(error) + } +} + +impl From for TaskError { + fn from(error: semver::Error) -> Self { + Self::Semver(error) + } +} + +impl From for TaskError { + fn from(error: SystemTimeError) -> Self { + Self::SystemTime(error) + } +} + +impl From for TaskError { + fn from(message: String) -> Self { + Self::Message(message) + } +} const PACKAGE_NAME: &str = "windows-spawn"; const PUBLIC_API_TOOLCHAIN: &str = "nightly-2026-07-02"; @@ -207,7 +281,7 @@ fn public_api(root: &Path, update: bool) -> Result<()> { } let expected = fs::read_to_string(&snapshot)?; - compare_snapshot(&expected, &actual).map_err(Into::into) + compare_snapshot(&expected, &actual).map_err(TaskError::from) } fn compare_snapshot(expected: &str, actual: &str) -> std::result::Result<(), String> { @@ -415,7 +489,7 @@ fn require_licenses(component: &Value) -> Result<()> { fn validate_reuse_spdx(path: &Path, package: &PackageInfo) -> Result<()> { let document = fs::read_to_string(path)?; - validate_reuse_spdx_text(&document, &package.name).map_err(Into::into) + validate_reuse_spdx_text(&document, &package.name).map_err(TaskError::from) } fn validate_reuse_spdx_text(document: &str, package_name: &str) -> std::result::Result<(), String> { @@ -738,14 +812,14 @@ fn root_package(root: &Path) -> Result { command.args(["metadata", "--locked", "--no-deps", "--format-version", "1"]); let output = capture(&mut command)?; let metadata: Value = serde_json::from_str(&output)?; - select_root_package(&metadata, root).map_err(Into::into) + select_root_package(&metadata, root) } -fn select_root_package(metadata: &Value, root: &Path) -> std::result::Result { +fn select_root_package(metadata: &Value, root: &Path) -> Result { let packages = metadata .get("packages") .and_then(Value::as_array) - .ok_or_else(|| "cargo metadata has no packages array".to_owned())?; + .ok_or_else(|| TaskError::Message("cargo metadata has no packages array".to_owned()))?; let root_manifest = normalize_path(&root.join("Cargo.toml")); let mut matches = packages.iter().filter(|package| { package @@ -753,23 +827,25 @@ fn select_root_package(metadata: &Value, root: &Path) -> std::result::Result Result { println!("+ {command:?}"); let output = command.output()?; ensure_success(command, &output)?; - String::from_utf8(output.stdout).map_err(Into::into) + String::from_utf8(output.stdout).map_err(TaskError::from) } fn ensure_success(command: &Command, output: &Output) -> Result<()> { @@ -856,13 +932,14 @@ fn ensure_success(command: &Command, output: &Output) -> Result<()> { } fn fail(message: impl Into) -> Result { - Err(io::Error::other(message.into()).into()) + Err(TaskError::Message(message.into())) } #[cfg(test)] mod tests { use super::*; use serde_json::json; + use std::time::Duration; #[test] fn selects_only_the_root_manifest_package() { @@ -909,6 +986,33 @@ mod tests { assert!(parse_release_tag("v1.2").is_err()); } + #[test] + fn task_error_preserves_typed_sources_and_messages() { + let io_error = TaskError::from(io::Error::new(io::ErrorKind::InvalidData, "io failure")); + assert_eq!(io_error.to_string(), "io failure"); + assert!(io_error.source().is_some()); + + let json_error = TaskError::from(serde_json::from_str::("{").unwrap_err()); + assert!(json_error.source().is_some()); + + let utf8_error = TaskError::from(String::from_utf8(vec![0xff]).unwrap_err()); + assert!(utf8_error.source().is_some()); + + let semver_error = TaskError::from(Version::parse("not-semver").unwrap_err()); + assert!(semver_error.source().is_some()); + + let time_error = TaskError::from( + UNIX_EPOCH + .duration_since(UNIX_EPOCH + Duration::from_secs(1)) + .unwrap_err(), + ); + assert!(time_error.source().is_some()); + + let message = TaskError::from("plain failure".to_owned()); + assert_eq!(message.to_string(), "plain failure"); + assert!(message.source().is_none()); + } + #[test] fn validates_sbom_formats_and_package_identity() { let package = PackageInfo {