diff --git a/.cargo_vcs_info.json b/.cargo_vcs_info.json deleted file mode 100644 index 14cf155..0000000 --- a/.cargo_vcs_info.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "git": { - "sha1": "f8921727a11b9f8b073e8c24821d72fd41283500" - }, - "path_in_vcs": "pty" -} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0c7f918 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + format: + name: Format Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@1.88.0 + with: + components: rustfmt + - run: cargo fmt --all -- --check + - run: sh -n scripts/run-owned-pty-empty.sh + - run: sh -n scripts/run-diabolical-docker.sh + + lint: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@1.88.0 + with: + components: clippy + - run: cargo clippy --all-targets --all-features -- -D warnings + + msrv: + name: MSRV (Rust 1.88.0) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@1.88.0 + - run: cargo build --all-targets --all-features + - run: cargo test --all-targets --all-features + + test: + name: Test (${{ matrix.target }}) + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: ubuntu-latest-arm64-s + target: aarch64-unknown-linux-gnu + - os: macos-latest + target: aarch64-apple-darwin + - os: windows-latest + target: x86_64-pc-windows-msvc + - os: windows-latest-arm64-s + target: aarch64-pc-windows-msvc + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@1.88.0 + - run: cargo test --all-targets --all-features diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/Cargo.lock b/Cargo.lock index bbc57f6..43117af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "anyhow" @@ -60,7 +60,7 @@ dependencies = [ "rustix", "slab", "tracing", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -119,7 +119,7 @@ dependencies = [ "rustix", "signal-hook-registry", "slab", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -177,6 +177,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -192,12 +198,37 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "ctrlc" +version = "3.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "697b5419f348fd5ae2478e8018cb016c00a5881c7f46c717de98ffd135a5651c" +dependencies = [ + "nix 0.29.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "downcast-rs" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.10" @@ -205,7 +236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -348,12 +379,34 @@ dependencies = [ "slab", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hermit-abi" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "lazy_static" version = "1.5.0" @@ -392,10 +445,34 @@ checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ "bitflags 2.8.0", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.1.1", "libc", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.8.0", + "cfg-if", + "cfg_aliases 0.2.2", + "libc", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "parking" version = "2.2.1" @@ -437,31 +514,14 @@ dependencies = [ "pin-project-lite", "rustix", "tracing", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] -name = "portable-pty" -version = "0.9.0" -dependencies = [ - "anyhow", - "bitflags 1.3.2", - "downcast-rs", - "filedescriptor", - "futures", - "lazy_static", - "libc", - "log", - "nix", - "serde", - "serde_derive", - "serial2", - "shared_library", - "shell-words", - "smol", - "winapi", - "winreg", -] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "proc-macro2" @@ -481,6 +541,20 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rsfulmen" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47400bda4eabdb1d346494637579a1fd896423d37fbd34f2fdbb9ed16797ae6" +dependencies = [ + "ctrlc", + "once_cell", + "serde", + "serde_yaml_ng", + "signal-hook", + "thiserror", +] + [[package]] name = "rustix" version = "0.38.44" @@ -491,9 +565,15 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.59.0", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.217" @@ -514,6 +594,31 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serial2" version = "0.2.28" @@ -541,6 +646,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -587,6 +702,89 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sysprims-core" +version = "0.2.2" +source = "git+https://github.com/3leaps/sysprims?rev=1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff#1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" +dependencies = [ + "rsfulmen", + "thiserror", + "time", +] + +[[package]] +name = "sysprims-proc" +version = "0.2.2" +source = "git+https://github.com/3leaps/sysprims?rev=1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff#1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" +dependencies = [ + "libc", + "serde", + "serde_json", + "sysprims-core", + "sysprims-signal", + "windows-sys 0.61.2", +] + +[[package]] +name = "sysprims-pty" +version = "0.9.0" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "futures", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serde", + "serde_derive", + "serial2", + "shared_library", + "shell-words", + "smol", + "sysprims-session", + "sysprims-timeout", + "winapi", + "winreg", +] + +[[package]] +name = "sysprims-session" +version = "0.2.2" +source = "git+https://github.com/3leaps/sysprims?rev=1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff#1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" +dependencies = [ + "libc", + "sysprims-core", +] + +[[package]] +name = "sysprims-signal" +version = "0.2.2" +source = "git+https://github.com/3leaps/sysprims?rev=1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff#1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" +dependencies = [ + "libc", + "rsfulmen", + "sysprims-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "sysprims-timeout" +version = "0.2.2" +source = "git+https://github.com/3leaps/sysprims?rev=1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff#1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" +dependencies = [ + "libc", + "rsfulmen", + "serde", + "sysprims-core", + "sysprims-proc", + "sysprims-session", + "sysprims-signal", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -607,6 +805,37 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tracing" version = "0.1.41" @@ -629,6 +858,12 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "winapi" version = "0.3.9" @@ -651,6 +886,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.59.0" @@ -660,6 +901,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index b8d1123..989cda8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,20 +11,25 @@ [package] edition = "2018" -name = "portable-pty" +rust-version = "1.88.0" +name = "sysprims-pty" version = "0.9.0" -authors = ["Wez Furlong"] +authors = [ + "Wez Furlong", + "3 Leaps, LLC", +] build = false autolib = false autobins = false autoexamples = false -autotests = false +autotests = true autobenches = false -description = "Cross platform pty interface" -documentation = "https://docs.rs/portable-pty" -readme = false +description = "Cross-platform PTY interface with sysprims containment" +documentation = "https://docs.rs/sysprims-pty" +readme = "README.md" +exclude = ["Cargo.toml.orig"] license = "MIT" -repository = "https://github.com/wezterm/wezterm" +repository = "https://github.com/3leaps/sysprims-pty" resolver = "2" [lib] @@ -83,6 +88,11 @@ version = "0.2" [dependencies.shell-words] version = "1.1" +[dependencies.sysprims-timeout] +git = "https://github.com/3leaps/sysprims" +rev = "1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" +version = "=0.2.2" + [dev-dependencies.futures] version = "0.3" @@ -118,3 +128,8 @@ features = [ [target."cfg(windows)".dependencies.winreg] version = "0.10" + +[target."cfg(unix)".dependencies.sysprims-session] +git = "https://github.com/3leaps/sysprims" +rev = "1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" +version = "=0.2.2" diff --git a/Cargo.toml.orig b/Cargo.toml.orig index 0c3d6b8..b703551 100644 --- a/Cargo.toml.orig +++ b/Cargo.toml.orig @@ -1,12 +1,15 @@ [package] -name = "portable-pty" +name = "sysprims-pty" version = "0.9.0" -authors = ["Wez Furlong"] +authors = ["Wez Furlong", "3 Leaps, LLC"] edition = "2018" -repository = "https://github.com/wezterm/wezterm" -description = "Cross platform pty interface" +rust-version = "1.88.0" +repository = "https://github.com/3leaps/sysprims-pty" +description = "Cross-platform PTY interface with sysprims containment" license = "MIT" -documentation = "https://docs.rs/portable-pty" +documentation = "https://docs.rs/sysprims-pty" +readme = "README.md" +exclude = ["Cargo.toml.orig"] [dependencies] anyhow = "1.0" @@ -19,6 +22,7 @@ shell-words = "1.1" serde_derive = {version="1.0", optional=true} serde = {version="1.0", optional=true} serial2 = "0.2" +sysprims-timeout = { version = "=0.2.2", git = "https://github.com/3leaps/sysprims", rev = "1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" } [features] default = [] @@ -38,6 +42,9 @@ winapi = { version = "0.3", features = [ ]} winreg = "0.10" +[target."cfg(unix)".dependencies] +sysprims-session = { version = "=0.2.2", git = "https://github.com/3leaps/sysprims", rev = "1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" } + [dev-dependencies] smol = "2.0" futures = "0.3" diff --git a/LICENSE.md b/LICENSE-MIT similarity index 97% rename from LICENSE.md rename to LICENSE-MIT index 7a93d12..7c0eea7 100644 --- a/LICENSE.md +++ b/LICENSE-MIT @@ -1,6 +1,7 @@ MIT License Copyright (c) 2018 Wez Furlong +Copyright (c) 2026 3 Leaps, LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..59d253d --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: help check test-owned-pty-empty test-diabolical + +help: + @printf '%s\n' \ + 'check Run formatting, lint, and host-safe tests' \ + 'test-owned-pty-empty Prove explicit-close and natural-exit PTY cleanup' \ + 'test-diabolical Run hostile containment scenes in disposable Docker' + +check: + cargo fmt --all -- --check + cargo clippy --all-targets --all-features -- -D warnings + cargo test --all-targets --all-features + +test-owned-pty-empty: + ./scripts/run-owned-pty-empty.sh + +test-diabolical: + ./scripts/run-diabolical-docker.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..478b80a --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# sysprims-pty + +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE-MIT) +[![Rust: 1.88+](https://img.shields.io/badge/rust-1.88%2B-orange.svg)](https://www.rust-lang.org/) + +Cross-platform **owned PTY sessions** with race-free process containment, +built on [sysprims](https://github.com/3leaps/sysprims). + +Use this crate when you need a real native PTY or ConPTY, opaque byte I/O and +resize, and an honest final outcome for the exact child and its descendants +when the tab, test, or agent closes. + +It is derived from [`portable-pty`](https://crates.io/crates/portable-pty) +0.9.0, so familiar command / pair / master / slave / I/O / resize types remain +available as a **migration lane**. Compatibility is the on-ramp, not the +product: this crate does not promise identity with upstream `portable-pty`, +and it adds what portable-pty leaves to you — race-free contained spawn and +an owned, verifiable process lifecycle. + +The package name is `sysprims-pty`. The library target is `portable_pty`, +so existing `portable_pty` imports can keep working after a package rename. +crates.io version coordinates will appear at first publish; they are not +resolvable yet. + +## What you can build + +One launch, one owned session, one honest final outcome. Use it for an agent +console, IDE terminal panel, interactive test harness, REPL supervisor, or +task runner that: + +1. calls `SlavePty::spawn_contained_command` — unsupported implementations + reject before spawning; there is no separate support-query API in this + tree, +2. on success, holds an owned `ContainmentGuard` from + that single spawn-time transaction, +3. talks to the PTY with opaque byte I/O and resize, +4. closes explicitly or lets the leader complete naturally, +5. reads containment, completion, and reap evidence with no process-group, + Job, or wait glue of your own. + +On Unix today, `SlavePty::spawn_contained_command` installs the prepared +sysprims acquisition hook in the PTY-owned spawn, validates its sealed +same-spawn receipt, and returns an owned `ContainmentGuard`. + +## What this is not + +- a terminal emulator (no VT parsing, scrollback, or rendering) +- a multiplexer or collaboration layer +- a shell policy / authorization product +- a general process supervisor or generic spawner +- a drop-in replacement that matches every `portable-pty` API and bug +- an OS non-escape sandbox + +Generic process identity, Job/group evidence, and receipts live in +`sysprims`. This crate composes those primitives around the PTY spawn seam. + +## Containment semantics + +- `TreeKillReliability::Guaranteed` means race-free session/group + acquisition and group-signaling eligibility. +- The guard exclusively owns child observation and reap through a terminal + lifecycle transition. +- Completion evidence is reported independently as `Empty`, `Survivors`, + or `Unknown`. +- A cooperative Unix descendant can still leave its acquired group. + Guaranteed acquisition is not an OS-enforced non-escape guarantee. +- Unsupported implementations, including Windows in this tree, reject the + guarded API before spawning. + +## Owned real-PTY examples + +The integration tests contain two small, executable public-API examples. Each +opens a real controlling PTY, starts at least one descendant, and proves +`Guaranteed` acquisition, supported `Empty` completion, and exact leader reap: + +1. [`owned_empty_explicit_close_with_descendant`](tests/contained_spawn.rs) + calls `ContainmentGuard::terminate`. +2. [`owned_empty_natural_leader_exit_with_descendant`](tests/contained_spawn.rs) + polls `ContainmentGuard::try_complete`, retrying only `Ok(None)` while the + leader is running. Any error fails the scene. + +```console +make test-owned-pty-empty +``` + +The runner uses the sibling `../sysprims` checkout by default, verifies a +clean exact revision, then applies it as a local source override in an +isolated copy. Set `SYSPRIMS_ROOT` to use another checkout. The package +manifest retains the released minimum dependency; this target proves a +separately reviewed candidate. + +The target is intentionally separate from ordinary CI because its evidence +depends on real Unix PTY and process-group behavior. `make help` lists the +other local verification targets. + +Application code should construct commands with `CommandBuilder`. The test +fixture invokes `/bin/sh` only to create deterministic descendant processes. + +## Provenance + +This tree is derived from the `pty` crate in the wezterm repository. See +[UPSTREAM.md](UPSTREAM.md) and [LICENSE-MIT](LICENSE-MIT). + +## License + +MIT. See [LICENSE-MIT](LICENSE-MIT). + +Upstream `portable-pty` is MIT (Copyright 2018 Wez Furlong). 3 Leaps +modifications are MIT as well. This crate is **not** dual-licensed +MIT/Apache-2.0 like `sysprims`; the upstream grant is MIT-only. + +This project follows the [3 Leaps OSS policies](https://github.com/3leaps/oss-policies). diff --git a/UPSTREAM.md b/UPSTREAM.md new file mode 100644 index 0000000..503b54f --- /dev/null +++ b/UPSTREAM.md @@ -0,0 +1,27 @@ +# Upstream provenance + +This source tree is derived from the `portable-pty` 0.9.0 crate. + +- Upstream repository: +- Published crate SHA-256: + `b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e` +- Upstream workspace path: `pty` +- Upstream source revision: + `f8921727a11b9f8b073e8c24821d72fd41283500` +- Upstream license: MIT; see `LICENSE-MIT` + +The compatibility delta is intentionally narrow: + +- an object-safe guarded-spawn method on `SlavePty`; +- a Unix implementation whose prepared sysprims acquisition hook replaces + portable-pty's internal `setsid` slot; +- an exact-child adapter owned by `sysprims_timeout::ContainmentGuard`; +- a pre-spawn parent recovery owner that retains the opaque exact child for + bounded failure attempts and nonblocking error destruction; +- pre-spawn rejection for unsupported implementations, including Windows; +- lifecycle, real-PTY, allocator-lock, and compatibility tests; and +- mechanical current-Clippy fixes that do not change behavior. + +The minimum sysprims contract is `v0.2.2`, commit +`1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff`. Compatibility is also checked +against that exact sysprims revision before a companion release is cut. diff --git a/examples/bash.rs b/examples/bash.rs index 2066b7e..accb328 100644 --- a/examples/bash.rs +++ b/examples/bash.rs @@ -38,10 +38,10 @@ fn main() { Ok(0) => break, // EOF Ok(n) => { let output = String::from_utf8_lossy(&buffer[..n]); - println!("{}", output); // Print to stdout for visibility. + println!("{output}"); // Print to stdout for visibility. } Err(e) => { - eprintln!("Error reading from PTY: {}", e); + eprintln!("Error reading from PTY: {e}"); break; } } @@ -72,7 +72,7 @@ fn main() { println!("Waiting for Bash to exit..."); let status = child.wait().unwrap(); - println!("Bash exited with status: {:?}", status); + println!("Bash exited with status: {status:?}"); } fn handle_input_stream(rx: std::sync::mpsc::Receiver, mut writer: Box) { diff --git a/examples/narrow.rs b/examples/narrow.rs index 5793b9b..f5e30ef 100644 --- a/examples/narrow.rs +++ b/examples/narrow.rs @@ -66,6 +66,7 @@ fn main() { // want to send data to the child, you'd set `to_write` to // that data and do it like this: let to_write = ""; + #[allow(clippy::const_is_empty)] if !to_write.is_empty() { // To avoid deadlock, wrt. reading and waiting, we send // data to the stdin of the child in a different thread. diff --git a/examples/whoami.rs b/examples/whoami.rs index f7e9cca..b21a8bd 100644 --- a/examples/whoami.rs +++ b/examples/whoami.rs @@ -63,6 +63,7 @@ fn main() { // want to send data to the child, you'd set `to_write` to // that data and do it like this: let to_write = ""; + #[allow(clippy::const_is_empty)] if !to_write.is_empty() { // To avoid deadlock, wrt. reading and waiting, we send // data to the stdin of the child in a different thread. @@ -89,6 +90,6 @@ fn main() { // printed out raw in another terminal. print!("output: "); for c in output.escape_debug() { - print!("{}", c); + print!("{c}"); } } diff --git a/examples/whoami_async.rs b/examples/whoami_async.rs index e32b6a9..4e76bc2 100644 --- a/examples/whoami_async.rs +++ b/examples/whoami_async.rs @@ -63,7 +63,7 @@ fn main() -> anyhow::Result<()> { // printed out raw in another terminal. print!("output: len={} ", line.len()); for c in line.escape_debug() { - print!("{}", c); + print!("{c}"); } println!(); } diff --git a/scripts/run-diabolical-docker.sh b/scripts/run-diabolical-docker.sh new file mode 100755 index 0000000..210676d --- /dev/null +++ b/scripts/run-diabolical-docker.sh @@ -0,0 +1,35 @@ +#!/bin/sh +set -eu + +companion_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +sysprims_root=${SYSPRIMS_ROOT:-"$companion_root/../sysprims"} +reviewed_sysprims_rev=1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff + +if ! docker info >/dev/null 2>&1; then + echo "docker daemon unavailable; start the reviewed disposable runtime" >&2 + exit 2 +fi + +test "$(git -C "$sysprims_root" rev-parse HEAD)" = "$reviewed_sysprims_rev" +test -z "$(git -C "$sysprims_root" status --short)" + +docker run --rm \ + --env SYSPRIMS_PTY_DISPOSABLE=1 \ + --volume "$companion_root:/work/companion:ro" \ + --volume "$sysprims_root:/work/sysprims:ro" \ + rust:1.88-bookworm \ + sh -eu -c ' + cp -R /work/companion /tmp/companion + cd /tmp/companion + sed -i \ + -e "/^\[dependencies.sysprims-timeout\]$/,/^$/c\\ +[dependencies.sysprims-timeout]\\ +path = \"/work/sysprims/crates/sysprims-timeout\"\\ +" \ + -e "/^\[target.\"cfg(unix)\".dependencies.sysprims-session\]$/,\$c\\ +[target.\"cfg(unix)\".dependencies.sysprims-session]\\ +path = \"/work/sysprims/crates/sysprims-session\"\\ +" \ + Cargo.toml + cargo test --test diabolical -- --ignored --test-threads=1 + ' diff --git a/scripts/run-owned-pty-empty.sh b/scripts/run-owned-pty-empty.sh new file mode 100755 index 0000000..2208bf7 --- /dev/null +++ b/scripts/run-owned-pty-empty.sh @@ -0,0 +1,73 @@ +#!/bin/sh +set -eu + +companion_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +sysprims_root=${SYSPRIMS_ROOT:-"$companion_root/../sysprims"} +reviewed_sysprims_rev=1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff + +case "$(uname -s)" in + Darwin|Linux) ;; + *) + echo "owned PTY Empty proof requires macOS or Linux" >&2 + exit 2 + ;; +esac + +actual_sysprims_rev=$(git -C "$sysprims_root" rev-parse HEAD) +if [ "$actual_sysprims_rev" != "$reviewed_sysprims_rev" ]; then + echo "sysprims must be at reviewed revision $reviewed_sysprims_rev" >&2 + echo "found $actual_sysprims_rev" >&2 + exit 2 +fi +if [ -n "$(git -C "$sysprims_root" status --short)" ]; then + echo "sysprims worktree must be clean" >&2 + exit 2 +fi + +work_root=$(mktemp -d "${TMPDIR:-/tmp}/sysprims-pty-owned-empty.XXXXXX") +trap 'rm -rf "$work_root"' EXIT HUP INT TERM + +mkdir -p "$work_root/companion" +tar -C "$companion_root" \ + --exclude .git \ + --exclude target \ + -cf - . | tar -C "$work_root/companion" -xf - + +awk \ + -v timeout_path="$sysprims_root/crates/sysprims-timeout" \ + -v session_path="$sysprims_root/crates/sysprims-session" \ + ' + $0 == "[dependencies.sysprims-timeout]" { + print + print "path = \"" timeout_path "\"" + replacing_timeout = 1 + next + } + replacing_timeout { + if ($0 == "") { + print + replacing_timeout = 0 + } + next + } + $0 == "[target.\"cfg(unix)\".dependencies.sysprims-session]" { + print + print "path = \"" session_path "\"" + replacing_session = 1 + next + } + replacing_session { next } + { print } + ' "$work_root/companion/Cargo.toml" >"$work_root/Cargo.toml" +mv "$work_root/Cargo.toml" "$work_root/companion/Cargo.toml" + +echo "sysprims candidate: $reviewed_sysprims_rev" +cd "$work_root/companion" +CARGO_TARGET_DIR="$companion_root/target/owned-pty-empty" \ + cargo test --test contained_spawn \ + 'unix::owned_empty_explicit_close_with_descendant' -- \ + --exact --ignored --nocapture +CARGO_TARGET_DIR="$companion_root/target/owned-pty-empty" \ + cargo test --test contained_spawn \ + 'unix::owned_empty_natural_leader_exit_with_descendant' -- \ + --exact --ignored --nocapture diff --git a/src/cmdbuilder.rs b/src/cmdbuilder.rs index b4ce50a..68087cd 100644 --- a/src/cmdbuilder.rs +++ b/src/cmdbuilder.rs @@ -138,56 +138,52 @@ fn get_base_env() -> BTreeMap { if let Ok(sys_env) = RegKey::predef(HKEY_LOCAL_MACHINE) .open_subkey("System\\CurrentControlSet\\Control\\Session Manager\\Environment") { - for res in sys_env.enum_values() { - if let Ok((name, value)) = res { - if name.to_ascii_lowercase() == "username" { - continue; - } - if let Ok(value) = reg_value_to_string(&value) { - log::trace!("adding SYS env: {:?} {:?}", name, value); - env.insert( - EnvEntry::map_key(name.clone().into()), - EnvEntry { - is_from_base_env: true, - preferred_key: name.into(), - value, - }, - ); - } + for (name, value) in sys_env.enum_values().flatten() { + if name.eq_ignore_ascii_case("username") { + continue; + } + if let Ok(value) = reg_value_to_string(&value) { + log::trace!("adding SYS env: {:?} {:?}", name, value); + env.insert( + EnvEntry::map_key(name.clone().into()), + EnvEntry { + is_from_base_env: true, + preferred_key: name.into(), + value, + }, + ); } } } if let Ok(sys_env) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment") { - for res in sys_env.enum_values() { - if let Ok((name, value)) = res { - if let Ok(value) = reg_value_to_string(&value) { - // Merge the system and user paths together - let value = if name.to_ascii_lowercase() == "path" { - match env.get(&EnvEntry::map_key(name.clone().into())) { - Some(entry) => { - let mut result = OsString::new(); - result.push(&entry.value); - result.push(";"); - result.push(&value); - result - } - None => value, + for (name, value) in sys_env.enum_values().flatten() { + if let Ok(value) = reg_value_to_string(&value) { + // Merge the system and user paths together + let value = if name.eq_ignore_ascii_case("path") { + match env.get(&EnvEntry::map_key(name.clone().into())) { + Some(entry) => { + let mut result = OsString::new(); + result.push(&entry.value); + result.push(";"); + result.push(&value); + result } - } else { - value - }; - - log::trace!("adding USER env: {:?} {:?}", name, value); - env.insert( - EnvEntry::map_key(name.clone().into()), - EnvEntry { - is_from_base_env: true, - preferred_key: name.into(), - value, - }, - ); - } + None => value, + } + } else { + value + }; + + log::trace!("adding USER env: {:?} {:?}", name, value); + env.insert( + EnvEntry::map_key(name.clone().into()), + EnvEntry { + is_from_base_env: true, + preferred_key: name.into(), + value, + }, + ); } } } @@ -308,7 +304,7 @@ impl CommandBuilder { EnvEntry { is_from_base_env: false, preferred_key: key, - value: value, + value, }, ); } @@ -448,7 +444,7 @@ impl CommandBuilder { if let Some(path) = self.resolve_path() { for path in std::env::split_paths(&path) { - let candidate = cwd.join(&path).join(&exe); + let candidate = cwd.join(&path).join(exe); if candidate.is_dir() { errors.push(format!("{} exists but is a directory", candidate.display())); @@ -501,8 +497,7 @@ impl CommandBuilder { let home = self.get_home_dir()?; let dir: &OsStr = self .cwd - .as_ref() - .map(|dir| dir.as_os_str()) + .as_deref() .filter(|dir| std::path::Path::new(dir).is_dir()) .unwrap_or(home.as_ref()); let shell = self.get_shell(); @@ -513,7 +508,7 @@ impl CommandBuilder { // Run the shell as a login shell by prefixing the shell's // basename with `-` and setting that as argv0 let basename = shell.rsplit('/').next().unwrap_or(&shell); - cmd.arg0(&format!("-{}", basename)); + cmd.arg0(format!("-{basename}")); cmd } else { let resolved = self.search_path(&self.args[0], dir)?; @@ -554,7 +549,7 @@ impl CommandBuilder { } } - get_shell().into() + get_shell() } fn get_home_dir(&self) -> anyhow::Result { @@ -583,7 +578,7 @@ impl CommandBuilder { let extensions = self.get_env("PATHEXT").unwrap_or(OsStr::new(".EXE")); for path in std::env::split_paths(&path) { // Check for exactly the user's string in this path dir - let candidate = path.join(&exe); + let candidate = path.join(exe); if candidate.exists() { return candidate.into_os_string(); } @@ -595,7 +590,7 @@ impl CommandBuilder { // PATHEXT includes the leading `.`, but `with_extension` // doesn't want that let ext = ext.to_str().expect("PATHEXT entries must be utf8"); - let path = path.join(&exe).with_extension(&ext[1..]); + let path = path.join(exe).with_extension(&ext[1..]); if path.exists() { return path.into_os_string(); } @@ -772,14 +767,14 @@ mod tests { fn test_env() { let mut cmd = CommandBuilder::new("dummy"); let package_authors = cmd.get_env("CARGO_PKG_AUTHORS"); - println!("package_authors: {:?}", package_authors); - assert!(package_authors == Some(OsStr::new("Wez Furlong"))); + println!("package_authors: {package_authors:?}"); + assert!(package_authors == Some(OsStr::new("Wez Furlong:3 Leaps, LLC"))); cmd.env("foo key", "foo value"); cmd.env("bar key", "bar value"); let iterated_envs = cmd.iter_extra_env_as_str().collect::>(); - println!("iterated_envs: {:?}", iterated_envs); + println!("iterated_envs: {iterated_envs:?}"); assert!(iterated_envs == vec![("bar key", "bar value"), ("foo key", "foo value")]); { @@ -787,7 +782,7 @@ mod tests { cmd.env_remove("foo key"); let iterated_envs = cmd.iter_extra_env_as_str().collect::>(); - println!("iterated_envs: {:?}", iterated_envs); + println!("iterated_envs: {iterated_envs:?}"); assert!(iterated_envs == vec![("bar key", "bar value")]); } @@ -796,7 +791,7 @@ mod tests { cmd.env_remove("bar key"); let iterated_envs = cmd.iter_extra_env_as_str().collect::>(); - println!("iterated_envs: {:?}", iterated_envs); + println!("iterated_envs: {iterated_envs:?}"); assert!(iterated_envs == vec![("foo key", "foo value")]); } @@ -805,7 +800,7 @@ mod tests { cmd.env_clear(); let iterated_envs = cmd.iter_extra_env_as_str().collect::>(); - println!("iterated_envs: {:?}", iterated_envs); + println!("iterated_envs: {iterated_envs:?}"); assert!(iterated_envs.is_empty()); } } diff --git a/src/lib.rs b/src/lib.rs index 3c04da4..c3743b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,14 +39,382 @@ //! use anyhow::Error; use downcast_rs::{impl_downcast, Downcast}; -#[cfg(unix)] -use libc; #[cfg(feature = "serde_support")] use serde_derive::*; use std::io::Result as IoResult; #[cfg(windows)] use std::os::windows::prelude::{AsRawHandle, RawHandle}; +/// A PTY child whose exclusive reap ownership can be transferred to sysprims. +/// +/// Callers receive this type only after a containment guard has finalized. +/// While containment is active, the guard keeps the child handle private. +#[derive(Debug)] +pub struct ContainedPtyChild { + pub(crate) child: std::process::Child, +} + +impl ContainedPtyChild { + #[cfg(unix)] + pub(crate) fn new(child: std::process::Child) -> Self { + Self { child } + } +} + +/// Stage at which a guarded PTY spawn failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContainedPtySpawnErrorStage { + /// No child was created. + BeforeSpawn, + /// The same-spawn receipt could not be validated. + Receipt, + /// The exact child and receipt could not be adopted into a guard. + Adoption, +} + +#[cfg(unix)] +enum FailedChildRecoveryCommand { + Adopt(ContainedPtyChild), + Attempt { + timeout: std::time::Duration, + #[cfg(test)] + fault: FailedChildRecoveryFault, + response: std::sync::mpsc::SyncSender>, + }, +} + +#[cfg(all(test, unix))] +#[derive(Clone, Copy)] +enum FailedChildRecoveryFault { + None, + KillError, + Deadline, +} + +#[cfg(unix)] +struct FailedChildRecovery { + sender: std::sync::mpsc::Sender, + pending: std::sync::Arc, +} + +#[cfg(unix)] +impl FailedChildRecovery { + fn prepare() -> IoResult { + let (sender, receiver) = std::sync::mpsc::channel(); + let pending = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let worker_pending = std::sync::Arc::clone(&pending); + std::thread::Builder::new() + .name("sysprims-pty-recovery".to_string()) + .spawn(move || failed_child_recovery_worker(receiver, worker_pending))?; + Ok(Self { sender, pending }) + } + + fn attach(&self, child: ContainedPtyChild) { + self.pending + .store(true, std::sync::atomic::Ordering::Release); + if let Err(error) = self.sender.send(FailedChildRecoveryCommand::Adopt(child)) { + let child = match error.0 { + FailedChildRecoveryCommand::Adopt(child) => child, + FailedChildRecoveryCommand::Attempt { .. } => unreachable!(), + }; + bounded_local_recovery_or_abort(child); + } + } + + fn attempt(&self, timeout: std::time::Duration) -> IoResult { + self.attempt_inner( + timeout, + #[cfg(test)] + FailedChildRecoveryFault::None, + ) + } + + fn attempt_inner( + &self, + timeout: std::time::Duration, + #[cfg(test)] fault: FailedChildRecoveryFault, + ) -> IoResult { + if !self.pending.load(std::sync::atomic::Ordering::Acquire) { + return Ok(true); + } + + let timeout = timeout.min(std::time::Duration::from_secs(2)); + let (response, result) = std::sync::mpsc::sync_channel(1); + self.sender + .send(FailedChildRecoveryCommand::Attempt { + timeout, + #[cfg(test)] + fault, + response, + }) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "exact-child recovery owner stopped", + ) + })?; + result + .recv_timeout(timeout + std::time::Duration::from_millis(250)) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("exact-child recovery response unavailable: {error}"), + ) + })? + } + + #[cfg(test)] + fn attempt_with_fault( + &self, + timeout: std::time::Duration, + fault: FailedChildRecoveryFault, + ) -> IoResult { + self.attempt_inner(timeout, fault) + } + + fn is_pending(&self) -> bool { + self.pending.load(std::sync::atomic::Ordering::Acquire) + } +} + +#[cfg(unix)] +fn failed_child_recovery_worker( + receiver: std::sync::mpsc::Receiver, + pending: std::sync::Arc, +) { + let mut child = None; + while let Ok(command) = receiver.recv() { + match command { + FailedChildRecoveryCommand::Adopt(adopted) => { + debug_assert!(child.is_none()); + child = Some(adopted); + } + FailedChildRecoveryCommand::Attempt { + timeout, + #[cfg(test)] + fault, + response, + } => { + let result = attempt_failed_child_recovery( + &mut child, + timeout, + #[cfg(test)] + fault, + ); + pending.store(child.is_some(), std::sync::atomic::Ordering::Release); + let _ = response.send(result); + } + } + } + + // Disconnect means the public error/controller was dropped. Ownership + // stays on this parent-side worker; the destructor itself never blocks. + while child.is_some() { + let _ = attempt_failed_child_recovery( + &mut child, + std::time::Duration::from_secs(2), + #[cfg(test)] + FailedChildRecoveryFault::None, + ); + pending.store(child.is_some(), std::sync::atomic::Ordering::Release); + if child.is_some() { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } +} + +#[cfg(unix)] +fn attempt_failed_child_recovery( + child: &mut Option, + timeout: std::time::Duration, + #[cfg(test)] fault: FailedChildRecoveryFault, +) -> IoResult { + let owned_child = match child.as_mut() { + Some(child) => child, + None => return Ok(true), + }; + + if owned_child.child.try_wait()?.is_some() { + child.take(); + return Ok(true); + } + + #[cfg(test)] + match fault { + FailedChildRecoveryFault::KillError => { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "injected exact-child kill failure", + )); + } + FailedChildRecoveryFault::Deadline => { + std::thread::sleep(timeout.min(std::time::Duration::from_millis(10))); + return Ok(false); + } + FailedChildRecoveryFault::None => {} + } + + // This is the exact spawn-owned std::process::Child handle. Never + // reconstruct signal authority from an observed PID. + owned_child.child.kill()?; + + let timeout = timeout.min(std::time::Duration::from_secs(2)); + let deadline = std::time::Instant::now() + timeout; + loop { + match owned_child.child.try_wait()? { + Some(_) => { + child.take(); + return Ok(true); + } + None if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + None => return Ok(false), + } + } +} + +#[cfg(unix)] +fn bounded_local_recovery_or_abort(mut child: ContainedPtyChild) -> ! { + let _ = child.child.kill(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + match child.child.try_wait() { + Ok(Some(_)) => { + std::process::abort(); + } + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Ok(None) | Err(_) => std::process::abort(), + } + } +} + +/// Error from a guarded PTY spawn. +/// +/// Errors after child creation retain an opaque exact-child recovery state. +/// The active child handle is never returned to the caller or discarded. +/// Dropping an error disconnects its bounded controller; a parent-side worker +/// keeps exact-child ownership through direct kill and reap. +pub struct ContainedPtySpawnError { + stage: ContainedPtySpawnErrorStage, + source: Error, + #[cfg(unix)] + recovery: Option, +} + +impl ContainedPtySpawnError { + pub(crate) fn before_spawn(source: Error) -> Self { + Self { + stage: ContainedPtySpawnErrorStage::BeforeSpawn, + source, + #[cfg(unix)] + recovery: None, + } + } + + #[cfg(unix)] + pub(crate) fn after_spawn( + stage: ContainedPtySpawnErrorStage, + source: Error, + child: ContainedPtyChild, + recovery: FailedChildRecovery, + ) -> Self { + debug_assert!(stage != ContainedPtySpawnErrorStage::BeforeSpawn); + recovery.attach(child); + let mut error = Self { + stage, + source, + recovery: Some(recovery), + }; + let _ = error.recover(std::time::Duration::from_secs(2)); + error + } + + #[cfg(all(test, unix))] + pub(crate) fn after_spawn_pending_for_test( + stage: ContainedPtySpawnErrorStage, + source: Error, + child: ContainedPtyChild, + recovery: FailedChildRecovery, + ) -> Self { + debug_assert!(stage != ContainedPtySpawnErrorStage::BeforeSpawn); + recovery.attach(child); + Self { + stage, + source, + recovery: Some(recovery), + } + } + + /// Report the stage at which the spawn failed. + pub fn stage(&self) -> ContainedPtySpawnErrorStage { + self.stage + } + + /// Whether exact-child recovery is still in progress. + pub fn recovery_pending(&self) -> bool { + #[cfg(unix)] + { + self.recovery + .as_ref() + .is_some_and(FailedChildRecovery::is_pending) + } + + #[cfg(not(unix))] + { + false + } + } + + /// Make one bounded exact-child recovery attempt. + /// + /// The attempt is capped at two seconds. Returns `true` when no child + /// remains to reap. + pub fn recover(&mut self, timeout: std::time::Duration) -> IoResult { + #[cfg(unix)] + { + match self.recovery.as_mut() { + Some(recovery) => recovery.attempt(timeout), + None => Ok(true), + } + } + + #[cfg(not(unix))] + { + let _ = timeout; + Ok(true) + } + } +} + +impl std::fmt::Debug for ContainedPtySpawnError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ContainedPtySpawnError") + .field("stage", &self.stage) + .field("source", &self.source) + .field("recovery_pending", &self.recovery_pending()) + .finish() + } +} + +impl std::fmt::Display for ContainedPtySpawnError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.source.fmt(formatter) + } +} + +impl std::error::Error for ContainedPtySpawnError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(self.source.as_ref()) + } +} + +/// An owned, spawn-time-acquired PTY containment capability. +pub type ContainedPtyGuard = sysprims_timeout::ContainmentGuard; + pub mod cmdbuilder; pub use cmdbuilder::CommandBuilder; @@ -163,6 +531,20 @@ impl_downcast!(ChildKiller); pub trait SlavePty { /// Spawns the command specified by the provided CommandBuilder fn spawn_command(&self, cmd: CommandBuilder) -> Result, Error>; + + /// Spawns a command with a sealed same-spawn sysprims containment guard. + /// + /// Native Unix PTYs provide guaranteed spawn-time acquisition. Other PTY + /// implementations reject this operation before spawning a process. + fn spawn_contained_command( + &self, + cmd: CommandBuilder, + ) -> Result { + let _ = cmd; + Err(ContainedPtySpawnError::before_spawn(anyhow::anyhow!( + "spawn-time PTY containment is unavailable for this PTY implementation" + ))) + } } /// Represents the exit status of a child process. @@ -214,7 +596,7 @@ impl From for ExitStatus { if let Some(signal) = status.signal() { let signame = unsafe { libc::strsignal(signal) }; let signal = if signame.is_null() { - format!("Signal {}", signal) + format!("Signal {signal}") } else { let signame = unsafe { std::ffi::CStr::from_ptr(signame) }; signame.to_string_lossy().to_string() @@ -243,7 +625,7 @@ impl std::fmt::Display for ExitStatus { write!(fmt, "Success") } else { match &self.signal { - Some(sig) => write!(fmt, "Terminated by {}", sig), + Some(sig) => write!(fmt, "Terminated by {sig}"), None => write!(fmt, "Exited with code {}", self.code), } } @@ -270,10 +652,7 @@ impl_downcast!(PtySystem); impl Child for std::process::Child { fn try_wait(&mut self) -> IoResult> { - std::process::Child::try_wait(self).map(|s| match s { - Some(s) => Some(s.into()), - None => None, - }) + std::process::Child::try_wait(self).map(|status| status.map(Into::into)) } fn wait(&mut self) -> IoResult { @@ -290,6 +669,44 @@ impl Child for std::process::Child { } } +impl Child for ContainedPtyChild { + fn try_wait(&mut self) -> IoResult> { + std::process::Child::try_wait(&mut self.child).map(|status| status.map(Into::into)) + } + + fn wait(&mut self) -> IoResult { + std::process::Child::wait(&mut self.child).map(Into::into) + } + + fn process_id(&self) -> Option { + Some(self.child.id()) + } + + #[cfg(windows)] + fn as_raw_handle(&self) -> Option { + Some(std::os::windows::io::AsRawHandle::as_raw_handle( + &self.child, + )) + } +} + +impl sysprims_timeout::ContainmentChild for ContainedPtyChild { + fn process_id(&self) -> Option { + Some(self.child.id()) + } + + fn try_wait(&mut self) -> IoResult { + std::process::Child::try_wait(&mut self.child).map(|status| status.is_some()) + } + + #[cfg(windows)] + fn raw_process_handle(&self) -> Option { + Some(std::os::windows::io::AsRawHandle::as_raw_handle( + &self.child, + )) + } +} + #[derive(Debug)] struct ProcessSignaller { pid: Option, @@ -397,6 +814,16 @@ impl ChildKiller for std::process::Child { } } +impl ChildKiller for ContainedPtyChild { + fn kill(&mut self) -> IoResult<()> { + ChildKiller::kill(&mut self.child) + } + + fn clone_killer(&self) -> Box { + ChildKiller::clone_killer(&self.child) + } +} + pub fn native_pty_system() -> Box { Box::new(NativePtySystem::default()) } diff --git a/src/unix.rs b/src/unix.rs index cc458ce..02da43b 100644 --- a/src/unix.rs +++ b/src/unix.rs @@ -1,6 +1,10 @@ //! Working with pseudo-terminals -use crate::{Child, CommandBuilder, MasterPty, PtyPair, PtySize, PtySystem, SlavePty}; +use crate::{ + Child, CommandBuilder, ContainedPtyChild, ContainedPtyGuard, ContainedPtySpawnError, + ContainedPtySpawnErrorStage, FailedChildRecovery, MasterPty, PtyPair, PtySize, PtySystem, + SlavePty, +}; use anyhow::{bail, Error}; use filedescriptor::FileDescriptor; use libc::{self, winsize}; @@ -296,6 +300,144 @@ impl PtyFd { Ok(child) } + + fn spawn_contained_command( + &self, + builder: CommandBuilder, + ) -> Result { + self.spawn_contained_command_with_fault(builder, ContainedSpawnFault::None) + } + + fn spawn_contained_command_with_fault( + &self, + builder: CommandBuilder, + _fault: ContainedSpawnFault, + ) -> Result { + let configured_umask = builder.umask; + let mut cmd = builder + .as_command() + .map_err(ContainedPtySpawnError::before_spawn)?; + let controlling_tty = builder.get_controlling_tty(); + let (hook, pending_receipt) = sysprims_session::prepare_session_acquisition() + .map_err(|error| ContainedPtySpawnError::before_spawn(error.into()))?; + + unsafe { + cmd.stdin( + self.as_stdio() + .map_err(|error| ContainedPtySpawnError::before_spawn(error.into()))?, + ) + .stdout( + self.as_stdio() + .map_err(|error| ContainedPtySpawnError::before_spawn(error.into()))?, + ) + .stderr( + self.as_stdio() + .map_err(|error| ContainedPtySpawnError::before_spawn(error.into()))?, + ) + .pre_exec(move || { + // Match the legacy PTY spawn's signal reset without + // allocating or consulting lock-backed state. + for signo in &[ + libc::SIGCHLD, + libc::SIGHUP, + libc::SIGINT, + libc::SIGQUIT, + libc::SIGTERM, + libc::SIGALRM, + ] { + libc::signal(*signo, libc::SIG_DFL); + } + + let empty_set: libc::sigset_t = std::mem::zeroed(); + libc::sigprocmask(libc::SIG_SETMASK, &empty_set, std::ptr::null_mut()); + + // This hook replaces the legacy setsid() slot. It is the + // only session or process-group acquirer for this spawn. + hook.acquire()?; + + #[allow(clippy::cast_lossless)] + if controlling_tty && libc::ioctl(0, libc::TIOCSCTTY as _, 0) == -1 { + return Err(io::Error::last_os_error()); + } + + // The PTY and sysprims companion descriptors are prepared + // with CLOEXEC. Avoid portable-pty's allocation-backed + // /dev/fd traversal in this post-fork path. + if let Some(mask) = configured_umask { + libc::umask(mask); + } + + Ok(()) + }); + } + + // Establish the parent-side exact-child recovery owner before spawn. + // If the spawn succeeds, the idle owner exits when this controller is + // dropped. If post-spawn validation fails, it receives the only Child. + let recovery = FailedChildRecovery::prepare() + .map_err(|error| ContainedPtySpawnError::before_spawn(error.into()))?; + let mut child = cmd + .spawn() + .map_err(|error| ContainedPtySpawnError::before_spawn(error.into()))?; + child.stdin.take(); + child.stdout.take(); + child.stderr.take(); + + let child = ContainedPtyChild::new(child); + #[cfg(test)] + if _fault == ContainedSpawnFault::Receipt { + drop(pending_receipt); + return Err(ContainedPtySpawnError::after_spawn_pending_for_test( + ContainedPtySpawnErrorStage::Receipt, + anyhow::anyhow!("injected receipt failure"), + child, + recovery, + )); + } + let receipt = match pending_receipt.into_receipt(child.child.id()) { + Ok(receipt) => receipt, + Err(error) => { + return Err(ContainedPtySpawnError::after_spawn( + ContainedPtySpawnErrorStage::Receipt, + error.into(), + child, + recovery, + )); + } + }; + + #[cfg(test)] + if _fault == ContainedSpawnFault::Adoption { + let _receipt = receipt; + return Err(ContainedPtySpawnError::after_spawn_pending_for_test( + ContainedPtySpawnErrorStage::Adoption, + anyhow::anyhow!("injected adoption failure"), + child, + recovery, + )); + } + + // SAFETY: `receipt` was sealed by this exact spawn's one-shot hook, + // and `child` is its exclusive, unreaped std::process::Child owner. + match unsafe { sysprims_timeout::contain_acquired_session(child, receipt) } { + Ok(guard) => Ok(guard), + Err(adoption) => Err(ContainedPtySpawnError::after_spawn( + ContainedPtySpawnErrorStage::Adoption, + adoption.error.into(), + adoption.child, + recovery, + )), + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ContainedSpawnFault { + None, + #[cfg(test)] + Receipt, + #[cfg(test)] + Adoption, } /// Represents the master end of a pty. @@ -338,6 +480,13 @@ impl SlavePty for UnixSlavePty { ) -> Result, Error> { Ok(Box::new(self.fd.spawn_command(builder)?)) } + + fn spawn_contained_command( + &self, + builder: CommandBuilder, + ) -> Result { + self.fd.spawn_contained_command(builder) + } } impl MasterPty for UnixMasterPty { @@ -412,3 +561,88 @@ impl Write for UnixMasterWriter { self.fd.flush() } } + +#[cfg(test)] +mod contained_spawn_failure_tests { + use super::*; + use crate::FailedChildRecoveryFault; + + fn injected_failure( + fault: ContainedSpawnFault, + stage: ContainedPtySpawnErrorStage, + ) -> (ContainedPtySpawnError, UnixMasterPty) { + let (master, slave) = openpty(PtySize::default()).unwrap(); + let mut command = CommandBuilder::new("/usr/bin/perl"); + command.args(["-e", "sleep 60"]); + + let error = match slave.fd.spawn_contained_command_with_fault(command, fault) { + Ok(_) => panic!("injected failure returned a guard"), + Err(error) => error, + }; + + assert_eq!(error.stage(), stage); + assert!( + error.recovery_pending(), + "injected live child was not retained by the error state" + ); + (error, master) + } + + fn wait_for_recovery_owner(pending: &std::sync::atomic::AtomicBool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while pending.load(std::sync::atomic::Ordering::Acquire) { + assert!( + std::time::Instant::now() < deadline, + "detached exact-child recovery owner did not reap" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + + #[test] + fn receipt_failure_retains_child_across_kill_error() { + let (mut error, _master) = injected_failure( + ContainedSpawnFault::Receipt, + ContainedPtySpawnErrorStage::Receipt, + ); + let injected = error.recovery.as_ref().unwrap().attempt_with_fault( + std::time::Duration::from_millis(10), + FailedChildRecoveryFault::KillError, + ); + assert!(injected.is_err()); + assert!(error.recovery_pending()); + assert!( + error.recover(std::time::Duration::from_secs(2)).unwrap(), + "exact-child recovery did not reach reap" + ); + assert!(!error.recovery_pending()); + } + + #[test] + fn adoption_failure_deadline_transfers_to_bounded_drop_owner() { + let (error, _master) = injected_failure( + ContainedSpawnFault::Adoption, + ContainedPtySpawnErrorStage::Adoption, + ); + let completed = error + .recovery + .as_ref() + .unwrap() + .attempt_with_fault( + std::time::Duration::from_millis(10), + FailedChildRecoveryFault::Deadline, + ) + .unwrap(); + assert!(!completed); + assert!(error.recovery_pending()); + + let pending = std::sync::Arc::clone(&error.recovery.as_ref().unwrap().pending); + let started = std::time::Instant::now(); + drop(error); + assert!( + started.elapsed() < std::time::Duration::from_millis(100), + "pending-error destructor blocked" + ); + wait_for_recovery_owner(&pending); + } +} diff --git a/src/win/conpty.rs b/src/win/conpty.rs index 0e9a18c..eebe087 100644 --- a/src/win/conpty.rs +++ b/src/win/conpty.rs @@ -89,7 +89,7 @@ impl MasterPty for ConPtyMasterPty { fn get_size(&self) -> Result { let inner = self.inner.lock().unwrap(); - Ok(inner.size.clone()) + Ok(inner.size) } fn try_clone_reader(&self) -> anyhow::Result> { diff --git a/src/win/procthreadattr.rs b/src/win/procthreadattr.rs index ad97d58..c15d81c 100644 --- a/src/win/procthreadattr.rs +++ b/src/win/procthreadattr.rs @@ -22,11 +22,8 @@ impl ProcThreadAttributeList { &mut bytes_required, ) }; - let mut data = Vec::with_capacity(bytes_required); - // We have the right capacity, so force the vec to consider itself - // that length. The contents of those bytes will be maintained - // by the win32 apis used in this impl. - unsafe { data.set_len(bytes_required) }; + // Initialize the allocation before passing it to the Win32 API. + let mut data = vec![0; bytes_required]; let attr_ptr = data.as_mut_slice().as_mut_ptr() as *mut _; let res = unsafe { diff --git a/src/win/psuedocon.rs b/src/win/psuedocon.rs index 13c7c5d..1351bbb 100644 --- a/src/win/psuedocon.rs +++ b/src/win/psuedocon.rs @@ -22,6 +22,7 @@ use winapi::um::winbase::{ use winapi::um::wincon::COORD; use winapi::um::winnt::HANDLE; +#[allow(clippy::upper_case_acronyms)] pub type HPCON = HANDLE; pub const PSUEDOCONSOLE_INHERIT_CURSOR: DWORD = 0x1; diff --git a/tests/allocator_lock.rs b/tests/allocator_lock.rs new file mode 100644 index 0000000..72b6cc3 --- /dev/null +++ b/tests/allocator_lock.rs @@ -0,0 +1,106 @@ +#![cfg(unix)] + +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use sysprims_timeout::TerminateTreeConfig; + +struct ForkSensitiveAllocator; + +static ALLOCATOR_LOCK: Mutex<()> = Mutex::new(()); +static PARENT_PID: AtomicI32 = AtomicI32::new(0); +static HOLD_LOCK: AtomicBool = AtomicBool::new(true); +static LOCK_HELD: AtomicBool = AtomicBool::new(false); + +impl ForkSensitiveAllocator { + fn block_if_fork_child() { + let parent_pid = PARENT_PID.load(Ordering::Acquire); + if parent_pid > 0 && unsafe { libc::getpid() } != parent_pid { + let _inherited_lock = ALLOCATOR_LOCK.lock().unwrap(); + } + } +} + +unsafe impl GlobalAlloc for ForkSensitiveAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + Self::block_if_fork_child(); + unsafe { System.alloc(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + Self::block_if_fork_child(); + unsafe { System.dealloc(ptr, layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + Self::block_if_fork_child(); + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + Self::block_if_fork_child(); + unsafe { System.realloc(ptr, layout, new_size) } + } +} + +#[global_allocator] +static GLOBAL_ALLOCATOR: ForkSensitiveAllocator = ForkSensitiveAllocator; + +#[test] +fn contained_pre_exec_does_not_touch_inherited_allocator_lock() { + PARENT_PID.store(unsafe { libc::getpid() }, Ordering::Release); + + let lock_holder = std::thread::spawn(|| { + let _guard = ALLOCATOR_LOCK.lock().unwrap(); + LOCK_HELD.store(true, Ordering::Release); + while HOLD_LOCK.load(Ordering::Acquire) { + std::thread::yield_now(); + } + }); + + while !LOCK_HELD.load(Ordering::Acquire) { + std::thread::yield_now(); + } + + let completed = std::sync::Arc::new(AtomicBool::new(false)); + let watchdog_completed = std::sync::Arc::clone(&completed); + let watchdog = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(10); + while !watchdog_completed.load(Ordering::Acquire) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + if !watchdog_completed.load(Ordering::Acquire) { + unsafe { libc::_exit(124) }; + } + }); + + let pair = native_pty_system().openpty(PtySize::default()).unwrap(); + let mut command = CommandBuilder::new("/usr/bin/true"); + command.set_controlling_tty(false); + let mut guard = pair.slave.spawn_contained_command(command).unwrap(); + + HOLD_LOCK.store(false, Ordering::Release); + lock_holder.join().unwrap(); + + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if guard + .try_complete(TerminateTreeConfig { + grace_timeout_ms: 50, + kill_timeout_ms: 1_000, + ..TerminateTreeConfig::default() + }) + .unwrap() + .is_some() + { + break; + } + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(10)); + } + + completed.store(true, Ordering::Release); + watchdog.join().unwrap(); +} diff --git a/tests/contained_spawn.rs b/tests/contained_spawn.rs new file mode 100644 index 0000000..1a96477 --- /dev/null +++ b/tests/contained_spawn.rs @@ -0,0 +1,232 @@ +#[cfg(unix)] +mod unix { + use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + use std::io::Read; + use std::sync::Mutex; + use std::time::{Duration, Instant}; + use sysprims_timeout::{ + ContainmentChild, ContainmentCompletionEvidence, TerminateTreeConfig, TreeKillReliability, + }; + + static PROCESS_TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn serialize_process_test() -> std::sync::MutexGuard<'static, ()> { + PROCESS_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn quick_termination() -> TerminateTreeConfig { + TerminateTreeConfig { + grace_timeout_ms: 50, + kill_timeout_ms: 1_000, + ..TerminateTreeConfig::default() + } + } + + fn wait_for_completion( + guard: &mut portable_pty::ContainedPtyGuard, + ) -> sysprims_timeout::ContainmentOutcome { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match guard.try_complete(quick_termination()) { + Ok(Some(outcome)) => return outcome, + Ok(None) => {} + Err(error) => panic!("contained PTY completion failed: {:?}", error), + } + assert!( + Instant::now() < deadline, + "contained PTY child did not complete" + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[test] + fn natural_exit_retains_exact_child_until_guard_finalizes() { + let _serial = serialize_process_test(); + let pair = native_pty_system().openpty(PtySize::default()).unwrap(); + let mut command = CommandBuilder::new("/bin/sh"); + command.args(["-c", "sleep 0.1; :; exit 0"]); + + let mut guard = pair.slave.spawn_contained_command(command).unwrap(); + assert_eq!( + guard.tree_kill_reliability(), + TreeKillReliability::Guaranteed + ); + + let outcome = wait_for_completion(&mut guard); + assert!(outcome.exited); + assert!(guard.try_complete(quick_termination()).is_err()); + assert!(guard.terminate(quick_termination()).is_err()); + assert!(guard.into_child().is_ok()); + } + + #[test] + fn child_observes_real_controlling_terminal() { + let _serial = serialize_process_test(); + let pair = native_pty_system().openpty(PtySize::default()).unwrap(); + let mut reader = pair.master.try_clone_reader().unwrap(); + let mut command = CommandBuilder::new("/usr/bin/perl"); + command.args([ + "-e", + "$| = 1; print((-t STDIN && -t STDOUT && -t STDERR) ? \"REAL_TTY\\n\" : \"NO_TTY\\n\"); sleep 60", + ]); + + let mut guard = pair.slave.spawn_contained_command(command).unwrap(); + let mut output = [0_u8; 64]; + let count = reader.read(&mut output).unwrap(); + let output = String::from_utf8_lossy(&output[..count]); + assert!(output.contains("REAL_TTY"), "child output: {:?}", output); + assert!(!output.contains("NO_TTY"), "child output: {:?}", output); + + let outcome = guard.terminate(quick_termination()).unwrap(); + assert!(outcome.exited); + } + + #[test] + #[ignore = "run with make test-owned-pty-empty against the reviewed sysprims candidate"] + fn owned_empty_explicit_close_with_descendant() { + let _serial = serialize_process_test(); + let pair = native_pty_system().openpty(PtySize::default()).unwrap(); + let mut reader = pair.master.try_clone_reader().unwrap(); + let mut command = CommandBuilder::new("/bin/sh"); + command.args(["-c", "sleep 60 & printf 'READY\\n'; wait"]); + + let mut guard = pair.slave.spawn_contained_command(command).unwrap(); + let mut ready = [0_u8; 64]; + let count = reader.read(&mut ready).unwrap(); + assert!(String::from_utf8_lossy(&ready[..count]).contains("READY")); + assert_eq!( + guard.tree_kill_reliability(), + TreeKillReliability::Guaranteed + ); + let outcome = guard.terminate(quick_termination()).unwrap(); + + assert!(outcome.exited); + assert_eq!( + outcome.tree_kill_reliability, + TreeKillReliability::Guaranteed + ); + assert!(matches!( + outcome.completion, + ContainmentCompletionEvidence::Empty { .. } + )); + let mut child = match guard.into_child() { + Ok(child) => child, + Err(_) => panic!("finalized guard did not return the exact child"), + }; + assert!(ContainmentChild::try_wait(&mut child).unwrap()); + } + + #[test] + #[ignore = "run with make test-owned-pty-empty against the reviewed sysprims candidate"] + fn owned_empty_natural_leader_exit_with_descendant() { + let _serial = serialize_process_test(); + let pair = native_pty_system().openpty(PtySize::default()).unwrap(); + let mut reader = pair.master.try_clone_reader().unwrap(); + let mut command = CommandBuilder::new("/bin/sh"); + command.args(["-c", "sleep 60 & sleep 60 & printf 'READY\\n'; exit 0"]); + + let mut guard = pair.slave.spawn_contained_command(command).unwrap(); + let mut ready = [0_u8; 64]; + let count = reader.read(&mut ready).unwrap(); + assert!(String::from_utf8_lossy(&ready[..count]).contains("READY")); + assert_eq!( + guard.tree_kill_reliability(), + TreeKillReliability::Guaranteed + ); + let outcome = wait_for_completion(&mut guard); + + assert!(outcome.exited); + assert_eq!( + outcome.tree_kill_reliability, + TreeKillReliability::Guaranteed + ); + assert!(matches!( + outcome.completion, + ContainmentCompletionEvidence::Empty { .. } + )); + let mut child = match guard.into_child() { + Ok(child) => child, + Err(_) => panic!("finalized guard did not return the exact child"), + }; + assert!(ContainmentChild::try_wait(&mut child).unwrap()); + } + + #[test] + fn presentation_handles_can_close_before_guard() { + let _serial = serialize_process_test(); + let pair = native_pty_system().openpty(PtySize::default()).unwrap(); + let mut reader = pair.master.try_clone_reader().unwrap(); + let mut command = CommandBuilder::new("/usr/bin/perl"); + command.args([ + "-e", + "$SIG{HUP} = 'IGNORE'; $| = 1; print \"READY\\n\"; sleep 60", + ]); + + let mut guard = pair.slave.spawn_contained_command(command).unwrap(); + let mut ready = [0_u8; 64]; + let count = reader.read(&mut ready).unwrap(); + assert!(String::from_utf8_lossy(&ready[..count]).contains("READY")); + drop(reader); + drop(pair); + + let outcome = guard.terminate(quick_termination()).unwrap(); + assert!(outcome.exited); + assert!(guard.into_child().is_ok()); + } + + #[test] + fn command_resolution_failure_happens_before_spawn() { + let _serial = serialize_process_test(); + let pair = native_pty_system().openpty(PtySize::default()).unwrap(); + let command = CommandBuilder::new("/definitely/not/a/real/executable"); + + let error = match pair.slave.spawn_contained_command(command) { + Ok(_) => panic!("missing command must fail"), + Err(error) => error, + }; + + assert!(error.to_string().contains("doesn't exist")); + } + + #[test] + fn rapid_exit_acknowledgement_races_remain_owned() { + let _serial = serialize_process_test(); + for _ in 0..32 { + let pair = native_pty_system().openpty(PtySize::default()).unwrap(); + let command = CommandBuilder::new("/usr/bin/true"); + let mut guard = pair.slave.spawn_contained_command(command).unwrap(); + + let outcome = wait_for_completion(&mut guard); + assert!(outcome.exited); + assert!(guard.into_child().is_ok()); + } + } +} + +#[cfg(windows)] +#[test] +fn guaranteed_containment_rejects_before_spawn() { + use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + + let marker = std::env::temp_dir().join(format!( + "portable-pty-contained-spawn-{}", + std::process::id() + )); + let mut command = CommandBuilder::new("cmd.exe"); + command.args(["/C", &format!("echo spawned>\"{}\"", marker.display())]); + let pair = native_pty_system().openpty(PtySize::default()).unwrap(); + + let error = match pair.slave.spawn_contained_command(command) { + Ok(_) => panic!("Windows guaranteed containment must reject"), + Err(error) => error, + }; + + assert!(error.to_string().contains("unavailable")); + assert!( + !marker.exists(), + "unsupported contained spawn executed the command" + ); +} diff --git a/tests/diabolical.rs b/tests/diabolical.rs new file mode 100644 index 0000000..6636d88 --- /dev/null +++ b/tests/diabolical.rs @@ -0,0 +1,114 @@ +#![cfg(unix)] + +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; +use std::io::Read; +use std::time::{Duration, Instant}; +use sysprims_timeout::{ContainmentCompletionEvidence, TerminateTreeConfig, TreeKillReliability}; + +fn require_disposable_container() { + assert_eq!( + std::env::var("SYSPRIMS_PTY_DISPOSABLE").as_deref(), + Ok("1"), + "diabolical tests require the disposable runner" + ); + assert!( + std::path::Path::new("/.dockerenv").exists(), + "diabolical tests must not run on the host" + ); +} + +fn escalation_config() -> TerminateTreeConfig { + TerminateTreeConfig { + grace_timeout_ms: 50, + kill_timeout_ms: 2_000, + ..TerminateTreeConfig::default() + } +} + +fn read_until(reader: &mut dyn Read, needle: &str) -> String { + let deadline = Instant::now() + Duration::from_secs(5); + let mut output = Vec::new(); + let mut buffer = [0_u8; 256]; + loop { + let count = reader.read(&mut buffer).unwrap(); + output.extend_from_slice(&buffer[..count]); + let text = String::from_utf8_lossy(&output); + if text.contains(needle) { + return text.into_owned(); + } + assert!( + count > 0 && Instant::now() < deadline, + "child output: {}", + text + ); + } +} + +#[test] +#[ignore = "requires the disposable container runner"] +fn term_resistant_descendants_require_bounded_escalation() { + require_disposable_container(); + let pair = native_pty_system().openpty(PtySize::default()).unwrap(); + let mut reader = pair.master.try_clone_reader().unwrap(); + let mut command = CommandBuilder::new("/usr/bin/perl"); + command.args([ + "-e", + "$SIG{TERM}='IGNORE'; for (1..3) { my $pid=fork(); die unless defined $pid; if (!$pid) { $SIG{TERM}='IGNORE'; sleep 60; exit 0; } } $|=1; print \"READY\\n\"; while (wait() > 0) {}", + ]); + + let mut guard = pair.slave.spawn_contained_command(command).unwrap(); + assert_eq!( + guard.tree_kill_reliability(), + TreeKillReliability::Guaranteed + ); + let output = read_until(&mut reader, "READY"); + assert!(output.contains("READY")); + + let outcome = guard.terminate(escalation_config()).unwrap(); + assert!(outcome.exited); + assert!(outcome.escalated); + assert!(matches!( + outcome.completion, + ContainmentCompletionEvidence::Empty { .. } + )); + assert!(guard.into_child().is_ok()); +} + +#[cfg(target_os = "linux")] +#[test] +#[ignore = "requires the disposable container runner"] +fn descendant_session_escape_does_not_upgrade_guarantee_to_non_escape() { + require_disposable_container(); + let pair = native_pty_system().openpty(PtySize::default()).unwrap(); + let mut reader = pair.master.try_clone_reader().unwrap(); + let mut command = CommandBuilder::new("/usr/bin/perl"); + command.args([ + "-MPOSIX=setsid", + "-e", + "my $pid=fork(); die unless defined $pid; if (!$pid) { setsid() >= 0 or die \"setsid\"; $|=1; print \"ESCAPED $$\\n\"; sleep 60; exit 0; } $|=1; print \"READY\\n\"; waitpid($pid, 0)", + ]); + + let mut guard = pair.slave.spawn_contained_command(command).unwrap(); + assert_eq!( + guard.tree_kill_reliability(), + TreeKillReliability::Guaranteed + ); + let output = read_until(&mut reader, "ESCAPED"); + let escaped_pid = output + .split_whitespace() + .collect::>() + .windows(2) + .find_map(|fields| (fields[0] == "ESCAPED").then(|| fields[1].parse::().unwrap())) + .expect("escaped child pid missing"); + + let outcome = guard.terminate(escalation_config()).unwrap(); + assert!(outcome.exited); + assert!( + std::path::Path::new(&format!("/proc/{escaped_pid}")).exists(), + "cooperative escape scene did not escape the acquired group" + ); + assert!(guard.into_child().is_ok()); + + // The escaped process is intentionally not signaled from this test. The + // disposable container boundary owns and destroys it after the suite. +}