From b9f5b650c108551d069fe7ee213a1244c35c7ffd Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 14 Aug 2026 16:59:10 -0700 Subject: [PATCH 01/39] Scaffold Rust UMDF proof of concept --- Cargo.toml | 1 + docs/design/virtual-serial-umdf-poc.md | 122 +++ docs/testing/n1mm-radio-poc.md | 64 ++ docs/testing/serial-client-inventory.md | 1 + .../.cargo/config.toml | 2 + drivers/cathub-virtual-serial-umdf/Cargo.lock | 922 ++++++++++++++++++ drivers/cathub-virtual-serial-umdf/Cargo.toml | 47 + .../cathub-virtual-serial-umdf/Makefile.toml | 13 + drivers/cathub-virtual-serial-umdf/README.md | 59 ++ drivers/cathub-virtual-serial-umdf/build.rs | 8 + .../cathub-virtual-serial-umdf.inx | 63 ++ .../rust-toolchain.toml | 5 + .../cathub-virtual-serial-umdf/src/interop.rs | 112 +++ drivers/cathub-virtual-serial-umdf/src/lib.rs | 10 + scripts/Test-UmdfPoc.ps1 | 73 ++ 15 files changed, 1502 insertions(+) create mode 100644 docs/design/virtual-serial-umdf-poc.md create mode 100644 docs/testing/n1mm-radio-poc.md create mode 100644 drivers/cathub-virtual-serial-umdf/.cargo/config.toml create mode 100644 drivers/cathub-virtual-serial-umdf/Cargo.lock create mode 100644 drivers/cathub-virtual-serial-umdf/Cargo.toml create mode 100644 drivers/cathub-virtual-serial-umdf/Makefile.toml create mode 100644 drivers/cathub-virtual-serial-umdf/README.md create mode 100644 drivers/cathub-virtual-serial-umdf/build.rs create mode 100644 drivers/cathub-virtual-serial-umdf/cathub-virtual-serial-umdf.inx create mode 100644 drivers/cathub-virtual-serial-umdf/rust-toolchain.toml create mode 100644 drivers/cathub-virtual-serial-umdf/src/interop.rs create mode 100644 drivers/cathub-virtual-serial-umdf/src/lib.rs create mode 100644 scripts/Test-UmdfPoc.ps1 diff --git a/Cargo.toml b/Cargo.toml index 8babdc1..bac769b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/cathub-protocol", "crates/cathub-virtual-serial", ] +exclude = ["drivers/cathub-virtual-serial-umdf"] [workspace.package] version = "0.2.1" diff --git a/docs/design/virtual-serial-umdf-poc.md b/docs/design/virtual-serial-umdf-poc.md new file mode 100644 index 0000000..8c9e55d --- /dev/null +++ b/docs/design/virtual-serial-umdf-poc.md @@ -0,0 +1,122 @@ +# Virtual serial UMDF proof of concept + +## Scope and status + +This is the isolated proof-of-concept branch for issue #6. +Phase 1 defined the private framing contract and conformance harness. +This milestone pins and scaffolds the pure Rust UMDF 2 binary before any device is installed. + +The scaffold is not a virtual COM driver yet. +Its INF uses the Windows Sample class and the driver only calls `WdfDriverCreate` and +`WdfDeviceCreate`. +Keeping the device non-serial prevents an incomplete driver from appearing usable to N1MM or +another station application. + +## Reproducible inputs + +| Input | Pin | +|---|---| +| Rust | `1.91.0` | +| Target | `x86_64-pc-windows-msvc` | +| `windows-drivers-rs` | `8e88dd899d9fa988df841e08cc01e9f663e5a415` | +| Driver model | UMDF 2.33 | +| Minimum Windows family | Windows 11 x64 | + +The Microsoft Rust driver repository is still experimental. +CatHub therefore pins a commit instead of a branch or loose crate version. +The driver is a separate workspace because the upstream build supports only one WDK configuration +in a Cargo build graph. + +## Local environment audit + +Audit date: 2026-08-14. + +Available on the development station: + +- Rust and Cargo 1.91.0 for `x86_64-pc-windows-msvc` +- LLVM/Clang 21.1.2 +- Visual Studio 2026 Build Tools and Visual Studio 2022 +- Windows SDK directories through 10.0.26100.0 +- N1MM Logger+ and isolated com0com pairs including COM20/COM21 + +Missing from the development station: + +- WDF headers such as `wdf.h` +- WDK packaging tools `inf2cat`, `infverif`, and `stampinf` +- `cargo-make` + +The SDK does provide `signtool`, but that does not make it a WDK environment. +No driver, certificate, or additional tool was installed during this audit. + +## Microsoft sample inventory + +The API reference is Microsoft VirtualSerial2 at commit +`717778a20ba4dd2440fe609f69153a1f8a64f597`. +The source remains upstream; CatHub does not copy the C implementation. + +### Callbacks and queues + +| Area | VirtualSerial2 behavior | CatHub PoC state | +|---|---|---| +| Driver | `DriverEntry`, `EVT_WDF_DRIVER_DEVICE_ADD` | Entry and device-add skeleton | +| Device | Device context and cleanup callback | Planned with endpoint state | +| Default queue | Parallel read, write, and device-control callbacks | Planned | +| Pending reads | Manual queue | Planned with bounded buffering and cancellation | +| Pending event wait | Separate manual queue | Planned, one outstanding wait policy required | +| Cleanup | Device cleanup releases COM mapping | Planned with daemon detach and fail-safe revocation | + +### Serial controls implemented by the sample + +VirtualSerial2 handles these controls in its device-control switch: + +- baud rate get/set +- line control get/set +- timeout get/set +- modem-control get/set and FIFO control +- wait-mask set and wait-on-mask +- queue-size set +- DTR set +- RTS set/clear +- XON/XOFF set +- serial characters get/set +- handflow get/set +- device reset + +The sample header defines more serial IOCTLs than its switch handles. +CatHub must not infer support from a definition alone. +The Phase 1 conformance profile additionally requires purge, queue/error status, cancellation, +overlapped I/O, and predictable unsupported-operation errors. + +### Device metadata used by the sample + +VirtualSerial2 uses the Ports class, `FILE_DEVICE_SERIAL_PORT`, `GUID_DEVINTERFACE_COMPORT`, a COM +name stored in the device map, and a symbolic link. +Its Windows 11 INF includes `WUDFRD.inf` and configures a UMDF service hosted through the reflector. + +CatHub will add a separate ACL-restricted private device interface and a stable endpoint ID. +Those are CatHub requirements, not behaviors supplied by VirtualSerial2. + +## Unsafe and FFI inventory + +The initial unsafe surface is one module, `src/interop.rs`: + +- exported `DriverEntry` +- `WdfDriverCreate` +- device-add callback and `WdfDeviceCreate` +- unload callback registration + +Every ABI entry catches panics. +There are no raw request buffers, context casts, ownership transfers, or asynchronous request races +yet. +Each of those categories must be added to this inventory when introduced. + +## Gates before the INF becomes a Ports-class package + +1. Install a supported WDK in the isolated development environment. +2. Build and package this sample-class driver with warnings treated as errors. +3. Add device and queue contexts with typed accessors. +4. Implement bounded application read/write queues, cancellation, cleanup, and timeout state. +5. Register `GUID_DEVINTERFACE_COMPORT` and a private CatHub interface. +6. Add the COM mapping only after restart and removal are deterministic. +7. Install only on the isolated test target with development-signing policy documented. +8. Run the `n1mm-radio` sequence in `docs/testing/n1mm-radio-poc.md`. diff --git a/docs/testing/n1mm-radio-poc.md b/docs/testing/n1mm-radio-poc.md new file mode 100644 index 0000000..432f1c8 --- /dev/null +++ b/docs/testing/n1mm-radio-poc.md @@ -0,0 +1,64 @@ +# N1MM radio CAT proof-of-concept runbook + +## Purpose + +This runbook is the first client evidence path for issue #6. +It exercises N1MM Logger+ through an isolated TS-590 loopback and never connects to a physical +radio or a keyer. + +COM21 is N1MM's application side and COM20 is CatHub's side on the current development station. +Confirm the pair before every application run; do not assume the numbers on another system. + +## Baseline before installing a CatHub driver + +1. Choose an unused isolated com0com pair and record both ports. + COM20/COM21 cannot be used while the station CatHub and N1MM processes own them. +2. Confirm both selected ports are com0com devices and no physical radio is selected. +3. Run the Phase 1 conformance profile: + + ```powershell + cargo run -p cathub-virtual-serial --bin serial-conformance -- run ` + --application-port ` + --peer-port ` + --profile n1mm-radio ` + --output docs\testing\evidence\n1mm-radio-com0com-baseline.json + ``` + +4. Preserve the JSON report with the branch evidence. +5. Stop the normal station CatHub process through its normal shutdown procedure. +6. Configure N1MM's radio as a TS-590 on COM21 with PTT and keying disabled. +7. Start a CatHub read-only TS-590 loopback configuration on COM20. +8. Capture serial API/IOCTL activity with the approved Windows tracing method. +9. In N1MM, connect, read frequency and mode, disconnect, reconnect, then close N1MM. +10. Redact raw CAT payloads from the saved application trace. +11. Record the trace tool/version, N1MM version, Windows build, port settings, and evidence path in + `serial-client-inventory.md`. +12. Stop the loopback instance and restore the normal station CatHub process. + +The conformance harness and N1MM cannot own COM21 at the same time. +The harness baseline and application trace are separate runs. + +## CatHub UMDF run + +Do not begin this section until the driver exposes a real Ports-class device and the private test +channel has bounded queues and deterministic cleanup. + +1. Use the isolated driver test target, not the working station setup. +2. Keep the existing com0com devices installed and unchanged for rollback. +3. Confirm the CatHub test process has no path to a physical radio or transmitter. +4. Start the CatHub-side test channel and record its endpoint identity. +5. Select the CatHub-owned COM port in N1MM as a TS-590 with PTT and keying disabled. +6. Exercise read-only frequency and mode queries with synchronous and overlapped traffic. +7. Stop and restart the CatHub-side process while a read is pending. +8. Restart the UMDF host in the isolated environment and verify bounded failure and reconnection. +9. Run the `n1mm-radio` conformance profile against the CatHub port and preserve its JSON report. +10. Capture UMDF diagnostics and a user-mode dump, then update the evidence inventory. + +## Pass conditions + +- N1MM never hangs during open, read, close, cancellation, or reconnect. +- Only the isolated endpoint receives the test bytes. +- A peer or UMDF-host failure completes outstanding work with a documented error. +- Reconnection does not require rebooting Windows. +- PTT and keying remain disabled for the entire run. +- The trace and conformance report are preserved before inventory states change. diff --git a/docs/testing/serial-client-inventory.md b/docs/testing/serial-client-inventory.md index 471082e..ca989ab 100644 --- a/docs/testing/serial-client-inventory.md +++ b/docs/testing/serial-client-inventory.md @@ -66,3 +66,4 @@ Add one row for each application trace or driver report. | Date | Profile | Environment | Evidence type | File or URL | Result | Notes | |---|---|---|---|---|---|---| | Not run | All | Not run | Planned | None | Pending | Phase 1 code and profiles exist. Application runs remain. | +| 2026-08-14 | `n1mm-radio` | Windows development station | Readiness check | [N1MM PoC runbook](n1mm-radio-poc.md) | Blocked | N1MM and the station CatHub process held COM21/COM20. The alternate pair was also in use. No trace was captured and no evidence state changed. | diff --git a/drivers/cathub-virtual-serial-umdf/.cargo/config.toml b/drivers/cathub-virtual-serial-umdf/.cargo/config.toml new file mode 100644 index 0000000..fac678a --- /dev/null +++ b/drivers/cathub-virtual-serial-umdf/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/drivers/cathub-virtual-serial-umdf/Cargo.lock b/drivers/cathub-virtual-serial-umdf/Cargo.lock new file mode 100644 index 0000000..03fb74c --- /dev/null +++ b/drivers/cathub-virtual-serial-umdf/Cargo.lock @@ -0,0 +1,922 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cathub-virtual-serial-umdf" +version = "0.0.1" +dependencies = [ + "wdk", + "wdk-build", + "wdk-sys", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap-cargo" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d546f0e84ff2bfa4da1ce9b54be42285767ba39c688572ca32412a09a73851e5" +dependencies = [ + "anstyle", + "clap", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scratch" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wdk" +version = "0.4.1" +source = "git+https://github.com/microsoft/windows-drivers-rs?rev=8e88dd899d9fa988df841e08cc01e9f663e5a415#8e88dd899d9fa988df841e08cc01e9f663e5a415" +dependencies = [ + "cfg-if", + "tracing", + "tracing-subscriber", + "wdk-build", + "wdk-sys", +] + +[[package]] +name = "wdk-build" +version = "0.5.1" +source = "git+https://github.com/microsoft/windows-drivers-rs?rev=8e88dd899d9fa988df841e08cc01e9f663e5a415#8e88dd899d9fa988df841e08cc01e9f663e5a415" +dependencies = [ + "anyhow", + "bindgen", + "camino", + "cargo_metadata", + "cfg-if", + "clap", + "clap-cargo", + "paste", + "regex", + "rustversion", + "semver", + "serde", + "serde_json", + "thiserror", + "tracing", + "windows", +] + +[[package]] +name = "wdk-macros" +version = "0.5.1" +source = "git+https://github.com/microsoft/windows-drivers-rs?rev=8e88dd899d9fa988df841e08cc01e9f663e5a415#8e88dd899d9fa988df841e08cc01e9f663e5a415" +dependencies = [ + "cfg-if", + "fs4", + "itertools", + "proc-macro2", + "quote", + "scratch", + "serde", + "serde_json", + "syn 2.0.119", +] + +[[package]] +name = "wdk-sys" +version = "0.5.1" +source = "git+https://github.com/microsoft/windows-drivers-rs?rev=8e88dd899d9fa988df841e08cc01e9f663e5a415#8e88dd899d9fa988df841e08cc01e9f663e5a415" +dependencies = [ + "anyhow", + "bindgen", + "cargo_metadata", + "cc", + "cfg-if", + "rustversion", + "serde_json", + "thiserror", + "tracing", + "tracing-subscriber", + "wdk-build", + "wdk-macros", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/drivers/cathub-virtual-serial-umdf/Cargo.toml b/drivers/cathub-virtual-serial-umdf/Cargo.toml new file mode 100644 index 0000000..666ed21 --- /dev/null +++ b/drivers/cathub-virtual-serial-umdf/Cargo.toml @@ -0,0 +1,47 @@ +[workspace] +members = ["."] +resolver = "3" + +[package] +name = "cathub-virtual-serial-umdf" +description = "Pure Rust UMDF 2 proof of concept for CatHub virtual serial endpoints" +version = "0.0.1" +edition = "2024" +rust-version = "1.91.0" +license = "MIT" +repository = "https://github.com/treitforge/cathub" +publish = false + +[package.metadata.wdk.driver-model] +driver-type = "UMDF" +umdf-version-major = 2 +target-umdf-version-minor = 33 + +[lib] +crate-type = ["cdylib"] + +[build-dependencies] +wdk-build = { git = "https://github.com/microsoft/windows-drivers-rs", rev = "8e88dd899d9fa988df841e08cc01e9f663e5a415" } + +[dependencies] +wdk = { git = "https://github.com/microsoft/windows-drivers-rs", rev = "8e88dd899d9fa988df841e08cc01e9f663e5a415" } +wdk-sys = { git = "https://github.com/microsoft/windows-drivers-rs", rev = "8e88dd899d9fa988df841e08cc01e9f663e5a415" } + +[dev-dependencies] +wdk-sys = { features = ["test-stubs"], git = "https://github.com/microsoft/windows-drivers-rs", rev = "8e88dd899d9fa988df841e08cc01e9f663e5a415" } + +[lints.rust] +missing_docs = "warn" +unsafe_op_in_unsafe_fn = "forbid" + +[lints.clippy] +all = { level = "deny", priority = -1 } +cargo = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +multiple_unsafe_ops_per_block = "forbid" +undocumented_unsafe_blocks = "forbid" +unnecessary_safety_doc = "forbid" + +[profile.release] +lto = true diff --git a/drivers/cathub-virtual-serial-umdf/Makefile.toml b/drivers/cathub-virtual-serial-umdf/Makefile.toml new file mode 100644 index 0000000..3a70e30 --- /dev/null +++ b/drivers/cathub-virtual-serial-umdf/Makefile.toml @@ -0,0 +1,13 @@ +[config] +min_version = "0.37.16" + +load_script = ''' +#!@rust +//! ```cargo +//! [dependencies] +//! wdk-build = { git = "https://github.com/microsoft/windows-drivers-rs", rev = "8e88dd899d9fa988df841e08cc01e9f663e5a415" } +//! ``` +#![allow(unused_doc_comments)] + +wdk_build::cargo_make::load_rust_driver_makefile()? +''' diff --git a/drivers/cathub-virtual-serial-umdf/README.md b/drivers/cathub-virtual-serial-umdf/README.md new file mode 100644 index 0000000..70a80c3 --- /dev/null +++ b/drivers/cathub-virtual-serial-umdf/README.md @@ -0,0 +1,59 @@ +# CatHub pure Rust UMDF proof of concept + +This isolated Cargo workspace proves the first build and ABI boundary for issue #6. +It is not part of the normal CatHub workspace because `windows-drivers-rs` supports one WDK +configuration per Cargo build graph. + +The current driver creates a sample-class WDF device only. +It does not register `GUID_DEVINTERFACE_COMPORT`, claim a COM number, or expose the private CatHub +interface. +Do not install it on the working station. + +## Pinned inputs + +- Rust `1.91.0`, selected by `rust-toolchain.toml` +- Microsoft `windows-drivers-rs` commit + `8e88dd899d9fa988df841e08cc01e9f663e5a415` +- UMDF `2.33` +- Windows 11 x64 target + +The upstream revision is intentionally repeated in `Cargo.toml` and `Makefile.toml` so neither the +driver build nor the packaging task follows a moving branch. + +## Prerequisites + +- A supported WDK environment with WDF headers, `inf2cat`, `infverif`, and `stampinf` +- Visual Studio C++ x64 build tools compatible with that WDK +- LLVM/libclang +- `cargo-make` 0.37.16 or newer + +An SDK-only installation is not enough. +Use a WDK or Enterprise WDK developer prompt so the matching tools are on `PATH`. + +## Build without installation + +From the repository root, run the driver-only check: + +```powershell +.\scripts\Test-UmdfPoc.ps1 +``` + +To produce the package without installing it: + +```powershell +.\scripts\Test-UmdfPoc.ps1 -Action Package +``` + +This command generates a test-signed package as part of the upstream sample workflow. +Building a package does not authorize installing its certificate or driver. +Keep any test certificate off normal operator machines. + +## Current safety boundary + +All Windows and WDF calls live in `src/interop.rs`. +Every exported or registered callback catches Rust panics before they can unwind into WDF. +The driver contains no kernel-mode CatHub code and no C or C++ shim. + +The next implementation step is the application COM interface and its bounded read/write queues. +That work must land with cancellation and cleanup behavior; the INF stays in the Sample class until +those behaviors exist. diff --git a/drivers/cathub-virtual-serial-umdf/build.rs b/drivers/cathub-virtual-serial-umdf/build.rs new file mode 100644 index 0000000..9f83420 --- /dev/null +++ b/drivers/cathub-virtual-serial-umdf/build.rs @@ -0,0 +1,8 @@ +// Copyright (c) CatHub contributors. +// SPDX-License-Identifier: MIT + +//! Configure Cargo to link the UMDF driver binary with the WDK. + +fn main() -> Result<(), wdk_build::ConfigError> { + wdk_build::configure_wdk_binary_build() +} diff --git a/drivers/cathub-virtual-serial-umdf/cathub-virtual-serial-umdf.inx b/drivers/cathub-virtual-serial-umdf/cathub-virtual-serial-umdf.inx new file mode 100644 index 0000000..5db9942 --- /dev/null +++ b/drivers/cathub-virtual-serial-umdf/cathub-virtual-serial-umdf.inx @@ -0,0 +1,63 @@ +; CatHub pure Rust UMDF 2 proof-of-concept package. +; This sample-class package does not expose a COM port yet. + +[Version] +Signature = "$WINDOWS NT$" +Class = Sample +ClassGuid = {78A1C341-4539-11d3-B88D-00C04FAD5171} +Provider = %ProviderString% +CatalogFile = cathub_virtual_serial_umdf.cat +PnpLockDown = 1 + +[DestinationDirs] +DefaultDestDir = 13 + +[SourceDisksNames] +1 = %DiskId1%,,,"" + +[SourceDisksFiles] +cathub_virtual_serial_umdf.dll = 1,, + +[ClassInstall32] +AddReg=SampleClassReg + +[SampleClassReg] +HKR,,,0,%ClassName% +HKR,,Icon,,-5 + +[Manufacturer] +%ManufacturerName%=Standard,NT$ARCH$.10.0...22000 + +[Standard.NT$ARCH$.10.0...22000] +%DeviceDesc%=CatHubUMDFDevice_Install, root\CATHUB_UMDF_POC + +[CatHubUMDFDevice_Install.NT] +CopyFiles=DriverCopy +Include=WUDFRD.inf +Needs=WUDFRD.NT + +[DriverCopy] +cathub_virtual_serial_umdf.dll + +[CatHubUMDFDevice_Install.NT.HW] +Include=WUDFRD.inf +Needs=WUDFRD.NT.HW + +[CatHubUMDFDevice_Install.NT.Services] +Include=WUDFRD.inf +Needs=WUDFRD.NT.Services + +[CatHubUMDFDevice_Install.NT.Wdf] +UmdfService=cathub_virtual_serial_umdf, CatHubUMDFDevice_WdfInstall +UmdfServiceOrder=cathub_virtual_serial_umdf + +[CatHubUMDFDevice_WdfInstall] +UmdfLibraryVersion=$UMDFVERSION$ +ServiceBinary=%13%\cathub_virtual_serial_umdf.dll + +[Strings] +ProviderString="CatHub contributors" +ManufacturerName="CatHub contributors" +DiskId1="CatHub UMDF proof-of-concept installation media" +DeviceDesc="CatHub UMDF proof-of-concept device" +ClassName="CatHub proof-of-concept devices" diff --git a/drivers/cathub-virtual-serial-umdf/rust-toolchain.toml b/drivers/cathub-virtual-serial-umdf/rust-toolchain.toml new file mode 100644 index 0000000..e9d879a --- /dev/null +++ b/drivers/cathub-virtual-serial-umdf/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "1.91.0" +components = ["clippy", "rustfmt"] +profile = "minimal" +targets = ["x86_64-pc-windows-msvc"] diff --git a/drivers/cathub-virtual-serial-umdf/src/interop.rs b/drivers/cathub-virtual-serial-umdf/src/interop.rs new file mode 100644 index 0000000..6d7663f --- /dev/null +++ b/drivers/cathub-virtual-serial-umdf/src/interop.rs @@ -0,0 +1,112 @@ +//! Auditable Windows/WDF FFI boundary. + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use wdk::println; +use wdk_sys::{ + NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, ULONG, WDF_DRIVER_CONFIG, WDF_NO_HANDLE, + WDF_NO_OBJECT_ATTRIBUTES, WDFDEVICE, WDFDEVICE_INIT, WDFDRIVER, + call_unsafe_wdf_function_binding, +}; + +const STATUS_UNSUCCESSFUL: NTSTATUS = -1_073_741_823; + +/// WDF driver entry point. +/// +/// No panic is allowed to unwind across this ABI boundary. +/// +/// # Safety +/// +/// `driver` and `registry_path` must be the valid pointers supplied by WDF. +// SAFETY: WDF requires this exported symbol, and this crate exports it exactly once. +#[cfg_attr(not(test), unsafe(export_name = "DriverEntry"))] +pub unsafe extern "system" fn driver_entry( + driver: PDRIVER_OBJECT, + registry_path: PCUNICODE_STRING, +) -> NTSTATUS { + ffi_status(|| { + // SAFETY: The caller is WDF and supplies both pointers under the DriverEntry contract. + unsafe { driver_entry_inner(driver, registry_path) } + }) +} + +unsafe fn driver_entry_inner(driver: PDRIVER_OBJECT, registry_path: PCUNICODE_STRING) -> NTSTATUS { + println!("CatHub UMDF proof-of-concept DriverEntry"); + + let config_size = + u32::try_from(core::mem::size_of::()).unwrap_or(ULONG::MAX); + let mut driver_config = WDF_DRIVER_CONFIG { + Size: config_size, + EvtDriverDeviceAdd: Some(evt_driver_device_add), + EvtDriverUnload: Some(evt_driver_unload), + ..WDF_DRIVER_CONFIG::default() + }; + let driver_attributes = WDF_NO_OBJECT_ATTRIBUTES; + let driver_handle_output = WDF_NO_HANDLE.cast::(); + + // SAFETY: WDF owns the two input pointers. The attributes and output handle may be null, + // and `driver_config` remains valid for the duration of the call. + unsafe { + call_unsafe_wdf_function_binding!( + WdfDriverCreate, + driver, + registry_path, + driver_attributes, + &mut driver_config, + driver_handle_output, + ) + } +} + +extern "C" fn evt_driver_device_add( + _driver: WDFDRIVER, + device_init: *mut WDFDEVICE_INIT, +) -> NTSTATUS { + ffi_status(|| { + // SAFETY: WDF supplies a valid, exclusively owned device-init pointer to this callback. + unsafe { create_device(device_init) } + }) +} + +unsafe fn create_device(mut device_init: *mut WDFDEVICE_INIT) -> NTSTATUS { + let mut device = WDF_NO_HANDLE.cast::(); + + // SAFETY: WDF supplies `device_init`; this callback consumes it exactly once on success. + // Null object attributes are allowed and `device` is a valid output location. + unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceCreate, + &mut device_init, + WDF_NO_OBJECT_ATTRIBUTES, + &mut device, + ) + } +} + +extern "C" fn evt_driver_unload(_driver: WDFDRIVER) { + ffi_void(|| println!("CatHub UMDF proof-of-concept unloaded")); +} + +fn ffi_status(action: impl FnOnce() -> NTSTATUS) -> NTSTATUS { + catch_unwind(AssertUnwindSafe(action)).unwrap_or(STATUS_UNSUCCESSFUL) +} + +fn ffi_void(action: impl FnOnce()) { + let _ = catch_unwind(AssertUnwindSafe(action)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn panic_is_contained_at_status_boundary() { + let status = ffi_status(|| panic!("test panic")); + assert_eq!(status, STATUS_UNSUCCESSFUL); + } + + #[test] + fn successful_status_crosses_boundary() { + assert_eq!(ffi_status(|| 0), 0); + } +} diff --git a/drivers/cathub-virtual-serial-umdf/src/lib.rs b/drivers/cathub-virtual-serial-umdf/src/lib.rs new file mode 100644 index 0000000..6aa886e --- /dev/null +++ b/drivers/cathub-virtual-serial-umdf/src/lib.rs @@ -0,0 +1,10 @@ +// Copyright (c) CatHub contributors. +// SPDX-License-Identifier: MIT + +//! Pure Rust UMDF 2 proof of concept for CatHub virtual serial endpoints. +//! +//! The current milestone only creates a WDF device. It deliberately does not +//! register a COM port or a private CatHub interface until their queues and +//! failure behavior are implemented. + +mod interop; diff --git a/scripts/Test-UmdfPoc.ps1 b/scripts/Test-UmdfPoc.ps1 new file mode 100644 index 0000000..89e32ae --- /dev/null +++ b/scripts/Test-UmdfPoc.ps1 @@ -0,0 +1,73 @@ +[CmdletBinding()] +param( + [ValidateSet('Check', 'Package')] + [string]$Action = 'Check' +) + +$ErrorActionPreference = 'Stop' +$driverRoot = Join-Path $PSScriptRoot '..\drivers\cathub-virtual-serial-umdf' + +function Assert-Command { + param([Parameter(Mandatory)][string]$Name) + + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "Required UMDF build command '$Name' is not available. Use a supported WDK developer prompt." + } +} + +function Assert-WdkHeaders { + $kitsRoot = (Get-ItemProperty ` + -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows Kits\Installed Roots' ` + -ErrorAction SilentlyContinue).KitsRoot10 + if (-not $kitsRoot) { + throw 'Windows Kits root is not registered. Install a supported Windows Driver Kit.' + } + + $crtDirectories = @(Get-ChildItem -LiteralPath (Join-Path $kitsRoot 'Include') -Directory | + Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'km\crt') }) + if ($crtDirectories.Count -eq 0) { + throw "The Windows SDK is present at '$kitsRoot', but WDK km/crt headers are missing." + } +} + +function Invoke-Checked { + param( + [Parameter(Mandatory)][string]$Command, + [Parameter(Mandatory)][string[]]$Arguments + ) + + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "'$Command $($Arguments -join ' ')' failed with exit code $LASTEXITCODE." + } +} + +Assert-Command cargo +Assert-Command clang +Assert-WdkHeaders + +Push-Location $driverRoot +try { + if ($Action -eq 'Package') { + foreach ($command in @('cargo-make', 'inf2cat', 'infverif', 'stampinf', 'signtool')) { + Assert-Command $command + } + Invoke-Checked cargo @('make', 'default', '--target', 'x86_64-pc-windows-msvc') + } + else { + Invoke-Checked cargo @('fmt', '--all', '--', '--check') + Invoke-Checked cargo @( + 'check', '--target', 'x86_64-pc-windows-msvc', '--locked' + ) + Invoke-Checked cargo @( + 'test', '--target', 'x86_64-pc-windows-msvc', '--locked' + ) + Invoke-Checked cargo @( + 'clippy', '--all-targets', '--target', 'x86_64-pc-windows-msvc', '--locked', + '--', '-D', 'warnings' + ) + } +} +finally { + Pop-Location +} From 6b48914fca67f9b2445f9b820f61176344b04104 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Sat, 15 Aug 2026 10:00:01 -0700 Subject: [PATCH 02/39] Build UMDF proof of concept with NuGet WDK --- docs/design/virtual-serial-umdf-poc.md | 28 +++--- drivers/cathub-virtual-serial-umdf/Cargo.toml | 6 +- .../cathub-virtual-serial-umdf/Makefile.toml | 7 ++ drivers/cathub-virtual-serial-umdf/README.md | 28 ++++-- ...mdf.inx => cathub_virtual_serial_umdf.inx} | 10 +-- .../cathub-virtual-serial-umdf/src/interop.rs | 8 +- drivers/cathub-virtual-serial-umdf/src/lib.rs | 5 +- scripts/Test-UmdfPoc.ps1 | 88 ++++++++++++++++--- 8 files changed, 135 insertions(+), 45 deletions(-) rename drivers/cathub-virtual-serial-umdf/{cathub-virtual-serial-umdf.inx => cathub_virtual_serial_umdf.inx} (87%) diff --git a/docs/design/virtual-serial-umdf-poc.md b/docs/design/virtual-serial-umdf-poc.md index 8c9e55d..f6a6b69 100644 --- a/docs/design/virtual-serial-umdf-poc.md +++ b/docs/design/virtual-serial-umdf-poc.md @@ -7,7 +7,7 @@ Phase 1 defined the private framing contract and conformance harness. This milestone pins and scaffolds the pure Rust UMDF 2 binary before any device is installed. The scaffold is not a virtual COM driver yet. -Its INF uses the Windows Sample class and the driver only calls `WdfDriverCreate` and +Its INF uses a private CatHub proof-of-concept class and the driver only calls `WdfDriverCreate` and `WdfDeviceCreate`. Keeping the device non-serial prevents an incomplete driver from appearing usable to N1MM or another station application. @@ -19,17 +19,19 @@ another station application. | Rust | `1.91.0` | | Target | `x86_64-pc-windows-msvc` | | `windows-drivers-rs` | `8e88dd899d9fa988df841e08cc01e9f663e5a415` | +| WDK NuGet package | `10.0.28000.2526` | +| SDK dependency | `10.0.28000.1721` | | Driver model | UMDF 2.33 | | Minimum Windows family | Windows 11 x64 | The Microsoft Rust driver repository is still experimental. CatHub therefore pins a commit instead of a branch or loose crate version. -The driver is a separate workspace because the upstream build supports only one WDK configuration -in a Cargo build graph. +The driver is an excluded, standalone Cargo package because the upstream build supports only one +WDK configuration in a Cargo build graph. ## Local environment audit -Audit date: 2026-08-14. +Audit date: 2026-08-15. Available on the development station: @@ -37,16 +39,14 @@ Available on the development station: - LLVM/Clang 21.1.2 - Visual Studio 2026 Build Tools and Visual Studio 2022 - Windows SDK directories through 10.0.26100.0 +- User-local Microsoft WDK NuGet package 10.0.28000.2526 and SDK dependency 10.0.28000.1721 +- `cargo-make` 0.37.24 and `rust-script` 0.36.0 - N1MM Logger+ and isolated com0com pairs including COM20/COM21 -Missing from the development station: - -- WDF headers such as `wdf.h` -- WDK packaging tools `inf2cat`, `infverif`, and `stampinf` -- `cargo-make` - -The SDK does provide `signtool`, but that does not make it a WDK environment. -No driver, certificate, or additional tool was installed during this audit. +The WDK package supplies the WDF headers and WDK validation/packaging tools without a machine-wide +installation or administrator access. The driver compiles against that package, `Inf2Cat` reports +zero signability errors or warnings, and `InfVerif` accepts the generated INF. No driver or +certificate was installed during this audit; the validated package is unsigned. ## Microsoft sample inventory @@ -112,8 +112,8 @@ Each of those categories must be added to this inventory when introduced. ## Gates before the INF becomes a Ports-class package -1. Install a supported WDK in the isolated development environment. -2. Build and package this sample-class driver with warnings treated as errors. +1. Restore the pinned WDK package in the isolated development environment. +2. Build and package this proof-of-concept-class driver with warnings treated as errors. 3. Add device and queue contexts with typed accessors. 4. Implement bounded application read/write queues, cancellation, cleanup, and timeout state. 5. Register `GUID_DEVINTERFACE_COMPORT` and a private CatHub interface. diff --git a/drivers/cathub-virtual-serial-umdf/Cargo.toml b/drivers/cathub-virtual-serial-umdf/Cargo.toml index 666ed21..789274b 100644 --- a/drivers/cathub-virtual-serial-umdf/Cargo.toml +++ b/drivers/cathub-virtual-serial-umdf/Cargo.toml @@ -1,7 +1,3 @@ -[workspace] -members = ["."] -resolver = "3" - [package] name = "cathub-virtual-serial-umdf" description = "Pure Rust UMDF 2 proof of concept for CatHub virtual serial endpoints" @@ -11,6 +7,7 @@ rust-version = "1.91.0" license = "MIT" repository = "https://github.com/treitforge/cathub" publish = false +resolver = "3" [package.metadata.wdk.driver-model] driver-type = "UMDF" @@ -40,6 +37,7 @@ cargo = { level = "warn", priority = -1 } nursery = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 } multiple_unsafe_ops_per_block = "forbid" +multiple_crate_versions = "allow" undocumented_unsafe_blocks = "forbid" unnecessary_safety_doc = "forbid" diff --git a/drivers/cathub-virtual-serial-umdf/Makefile.toml b/drivers/cathub-virtual-serial-umdf/Makefile.toml index 3a70e30..98ee358 100644 --- a/drivers/cathub-virtual-serial-umdf/Makefile.toml +++ b/drivers/cathub-virtual-serial-umdf/Makefile.toml @@ -1,3 +1,5 @@ +extend = "target/rust-driver-makefile.toml" + [config] min_version = "0.37.16" @@ -11,3 +13,8 @@ load_script = ''' wdk_build::cargo_make::load_rust_driver_makefile()? ''' + +[tasks.package-unsigned] +description = "Build and validate an unsigned driver package without creating a certificate" +dependencies = ["copy-pdb-to-package", "copy-map-to-package", "inf2cat", "infverif"] +workspace = false diff --git a/drivers/cathub-virtual-serial-umdf/README.md b/drivers/cathub-virtual-serial-umdf/README.md index 70a80c3..ea6ca75 100644 --- a/drivers/cathub-virtual-serial-umdf/README.md +++ b/drivers/cathub-virtual-serial-umdf/README.md @@ -1,10 +1,10 @@ # CatHub pure Rust UMDF proof of concept -This isolated Cargo workspace proves the first build and ABI boundary for issue #6. +This isolated Cargo package proves the first build and ABI boundary for issue #6. It is not part of the normal CatHub workspace because `windows-drivers-rs` supports one WDK configuration per Cargo build graph. -The current driver creates a sample-class WDF device only. +The current driver creates a private proof-of-concept-class WDF device only. It does not register `GUID_DEVINTERFACE_COMPORT`, claim a COM number, or expose the private CatHub interface. Do not install it on the working station. @@ -15,6 +15,7 @@ Do not install it on the working station. - Microsoft `windows-drivers-rs` commit `8e88dd899d9fa988df841e08cc01e9f663e5a415` - UMDF `2.33` +- Microsoft WDK NuGet package `10.0.28000.2526` - Windows 11 x64 target The upstream revision is intentionally repeated in `Cargo.toml` and `Makefile.toml` so neither the @@ -22,13 +23,21 @@ driver build nor the packaging task follows a moving branch. ## Prerequisites -- A supported WDK environment with WDF headers, `inf2cat`, `infverif`, and `stampinf` +- Microsoft WDK `10.0.28000.2526`, either installed or restored as a NuGet package - Visual Studio C++ x64 build tools compatible with that WDK - LLVM/libclang - `cargo-make` 0.37.16 or newer An SDK-only installation is not enough. -Use a WDK or Enterprise WDK developer prompt so the matching tools are on `PATH`. +The check script automatically finds an installed WDK, `WDKContentRoot`, or the newest package +under `%LOCALAPPDATA%\CatHub\wdk\packages`. +The pinned package can be restored without administrator access: + +```powershell +nuget install Microsoft.Windows.WDK.x64 -Version 10.0.28000.2526 ` + -OutputDirectory "$env:LOCALAPPDATA\CatHub\wdk\packages" ` + -NonInteractive -DirectDownload -Source https://api.nuget.org/v3/index.json +``` ## Build without installation @@ -38,13 +47,20 @@ From the repository root, run the driver-only check: .\scripts\Test-UmdfPoc.ps1 ``` -To produce the package without installing it: +To build and validate an unsigned package without creating a certificate: + +```powershell +.\scripts\Test-UmdfPoc.ps1 -Action ValidatePackage +``` + +On an isolated driver-development target, produce the upstream test-signed package with: ```powershell .\scripts\Test-UmdfPoc.ps1 -Action Package ``` -This command generates a test-signed package as part of the upstream sample workflow. +The `Package` action generates a certificate in the upstream workflow's private test store and +uses it to sign the package. It requires `makecert` and `signtool`. Building a package does not authorize installing its certificate or driver. Keep any test certificate off normal operator machines. diff --git a/drivers/cathub-virtual-serial-umdf/cathub-virtual-serial-umdf.inx b/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx similarity index 87% rename from drivers/cathub-virtual-serial-umdf/cathub-virtual-serial-umdf.inx rename to drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx index 5db9942..f976b88 100644 --- a/drivers/cathub-virtual-serial-umdf/cathub-virtual-serial-umdf.inx +++ b/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx @@ -1,10 +1,10 @@ ; CatHub pure Rust UMDF 2 proof-of-concept package. -; This sample-class package does not expose a COM port yet. +; This private proof-of-concept class does not expose a COM port yet. [Version] Signature = "$WINDOWS NT$" -Class = Sample -ClassGuid = {78A1C341-4539-11d3-B88D-00C04FAD5171} +Class = CatHubPoC +ClassGuid = {513D0917-B448-4C5D-ADBF-14E156572683} Provider = %ProviderString% CatalogFile = cathub_virtual_serial_umdf.cat PnpLockDown = 1 @@ -19,9 +19,9 @@ DefaultDestDir = 13 cathub_virtual_serial_umdf.dll = 1,, [ClassInstall32] -AddReg=SampleClassReg +AddReg=CatHubPoCClassReg -[SampleClassReg] +[CatHubPoCClassReg] HKR,,,0,%ClassName% HKR,,Icon,,-5 diff --git a/drivers/cathub-virtual-serial-umdf/src/interop.rs b/drivers/cathub-virtual-serial-umdf/src/interop.rs index 6d7663f..c47a476 100644 --- a/drivers/cathub-virtual-serial-umdf/src/interop.rs +++ b/drivers/cathub-virtual-serial-umdf/src/interop.rs @@ -52,7 +52,7 @@ unsafe fn driver_entry_inner(driver: PDRIVER_OBJECT, registry_path: PCUNICODE_ST driver, registry_path, driver_attributes, - &mut driver_config, + &raw mut driver_config, driver_handle_output, ) } @@ -69,16 +69,16 @@ extern "C" fn evt_driver_device_add( } unsafe fn create_device(mut device_init: *mut WDFDEVICE_INIT) -> NTSTATUS { - let mut device = WDF_NO_HANDLE.cast::(); + let mut device: WDFDEVICE = WDF_NO_HANDLE.cast(); // SAFETY: WDF supplies `device_init`; this callback consumes it exactly once on success. // Null object attributes are allowed and `device` is a valid output location. unsafe { call_unsafe_wdf_function_binding!( WdfDeviceCreate, - &mut device_init, + &raw mut device_init, WDF_NO_OBJECT_ATTRIBUTES, - &mut device, + &raw mut device, ) } } diff --git a/drivers/cathub-virtual-serial-umdf/src/lib.rs b/drivers/cathub-virtual-serial-umdf/src/lib.rs index 6aa886e..c3671a9 100644 --- a/drivers/cathub-virtual-serial-umdf/src/lib.rs +++ b/drivers/cathub-virtual-serial-umdf/src/lib.rs @@ -1,10 +1,11 @@ // Copyright (c) CatHub contributors. // SPDX-License-Identifier: MIT -//! Pure Rust UMDF 2 proof of concept for CatHub virtual serial endpoints. +//! Pure Rust UMDF 2 proof of concept for `CatHub` virtual serial endpoints. //! //! The current milestone only creates a WDF device. It deliberately does not -//! register a COM port or a private CatHub interface until their queues and +//! register a COM port or a private `CatHub` interface until their queues and //! failure behavior are implemented. +#[cfg_attr(test, allow(dead_code))] mod interop; diff --git a/scripts/Test-UmdfPoc.ps1 b/scripts/Test-UmdfPoc.ps1 index 89e32ae..3dafbee 100644 --- a/scripts/Test-UmdfPoc.ps1 +++ b/scripts/Test-UmdfPoc.ps1 @@ -1,6 +1,6 @@ [CmdletBinding()] param( - [ValidateSet('Check', 'Package')] + [ValidateSet('Check', 'ValidatePackage', 'Package')] [string]$Action = 'Check' ) @@ -15,19 +15,77 @@ function Assert-Command { } } -function Assert-WdkHeaders { +function Test-WdkContentRoot { + param([Parameter(Mandatory)][string]$Path) + + if (-not (Test-Path -LiteralPath (Join-Path $Path 'Include'))) { + return $false + } + + return @(Get-ChildItem -LiteralPath (Join-Path $Path 'Include') -Directory | + Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'km\crt') }).Count -gt 0 +} + +function Find-WdkContentRoot { + if ($env:WDKContentRoot -and (Test-WdkContentRoot $env:WDKContentRoot)) { + return (Resolve-Path -LiteralPath $env:WDKContentRoot).Path + } + + $packageRoot = Join-Path $env:LOCALAPPDATA 'CatHub\wdk\packages' + if (Test-Path -LiteralPath $packageRoot) { + $packagePrefix = 'Microsoft.Windows.WDK.x64.' + $packages = @(Get-ChildItem -LiteralPath $packageRoot -Directory | + Where-Object { + $_.Name.StartsWith($packagePrefix) -and (Test-WdkContentRoot (Join-Path $_.FullName 'c')) + } | + Sort-Object { [version]$_.Name.Substring($packagePrefix.Length) } -Descending) + if ($packages.Count -gt 0) { + return (Join-Path $packages[0].FullName 'c') + } + } + $kitsRoot = (Get-ItemProperty ` -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows Kits\Installed Roots' ` -ErrorAction SilentlyContinue).KitsRoot10 - if (-not $kitsRoot) { - throw 'Windows Kits root is not registered. Install a supported Windows Driver Kit.' + if ($kitsRoot -and (Test-WdkContentRoot $kitsRoot)) { + return $kitsRoot + } + + throw @" +No complete WDK was found. Install the pinned user-local package with: +nuget install Microsoft.Windows.WDK.x64 -Version 10.0.28000.2526 -OutputDirectory `"$packageRoot`" -NonInteractive -DirectDownload -Source https://api.nuget.org/v3/index.json +"@ +} + +function Add-PathEntry { + param([Parameter(Mandatory)][string]$Path) + + if ((Test-Path -LiteralPath $Path) -and ($env:Path -split ';' -notcontains $Path)) { + $env:Path = "$Path;$env:Path" } +} + +function Initialize-WdkEnvironment { + $contentRoot = Find-WdkContentRoot + $versionDirectory = Get-ChildItem -LiteralPath (Join-Path $contentRoot 'Include') -Directory | + Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'km\crt') } | + Sort-Object { [version]$_.Name } -Descending | + Select-Object -First 1 - $crtDirectories = @(Get-ChildItem -LiteralPath (Join-Path $kitsRoot 'Include') -Directory | - Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'km\crt') }) - if ($crtDirectories.Count -eq 0) { - throw "The Windows SDK is present at '$kitsRoot', but WDK km/crt headers are missing." + $env:WDKContentRoot = $contentRoot + $binRoot = Join-Path $contentRoot "bin\$($versionDirectory.Name)" + $toolRoot = Join-Path $contentRoot "tools\$($versionDirectory.Name)" + if (Test-Path -LiteralPath $binRoot) { + $env:WDKBinRoot = $binRoot + Add-PathEntry (Join-Path $binRoot 'x86') + Add-PathEntry (Join-Path $binRoot 'x64') + } + if (Test-Path -LiteralPath $toolRoot) { + $env:WDKToolRoot = $toolRoot + Add-PathEntry (Join-Path $toolRoot 'x64') } + + Write-Host "Using WDK $($versionDirectory.Name) from '$contentRoot'." } function Invoke-Checked { @@ -44,16 +102,26 @@ function Invoke-Checked { Assert-Command cargo Assert-Command clang -Assert-WdkHeaders +Initialize-WdkEnvironment Push-Location $driverRoot try { if ($Action -eq 'Package') { - foreach ($command in @('cargo-make', 'inf2cat', 'infverif', 'stampinf', 'signtool')) { + foreach ($command in @( + 'cargo-make', 'inf2cat', 'infverif', 'stampinf', 'makecert', 'signtool' + )) { Assert-Command $command } Invoke-Checked cargo @('make', 'default', '--target', 'x86_64-pc-windows-msvc') } + elseif ($Action -eq 'ValidatePackage') { + foreach ($command in @('cargo-make', 'inf2cat', 'infverif', 'stampinf')) { + Assert-Command $command + } + Invoke-Checked cargo @( + 'make', 'package-unsigned', '--target', 'x86_64-pc-windows-msvc' + ) + } else { Invoke-Checked cargo @('fmt', '--all', '--', '--check') Invoke-Checked cargo @( From 111a1ffdf4cfb942c3d163e6fefa693c6bb9ffda Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 13:17:34 -0700 Subject: [PATCH 03/39] Add bounded UMDF proof-of-concept channel --- docs/design/virtual-serial-umdf-poc.md | 34 +- drivers/cathub-virtual-serial-umdf/README.md | 23 +- .../src/data_plane.rs | 320 +++++++++ .../cathub-virtual-serial-umdf/src/interop.rs | 605 +++++++++++++++++- drivers/cathub-virtual-serial-umdf/src/lib.rs | 8 +- 5 files changed, 948 insertions(+), 42 deletions(-) create mode 100644 drivers/cathub-virtual-serial-umdf/src/data_plane.rs diff --git a/docs/design/virtual-serial-umdf-poc.md b/docs/design/virtual-serial-umdf-poc.md index f6a6b69..bdf4874 100644 --- a/docs/design/virtual-serial-umdf-poc.md +++ b/docs/design/virtual-serial-umdf-poc.md @@ -4,11 +4,13 @@ This is the isolated proof-of-concept branch for issue #6. Phase 1 defined the private framing contract and conformance harness. -This milestone pins and scaffolds the pure Rust UMDF 2 binary before any device is installed. +This milestone pins the pure Rust UMDF 2 binary and adds its first private byte-transfer channel +before any device is installed. The scaffold is not a virtual COM driver yet. -Its INF uses a private CatHub proof-of-concept class and the driver only calls `WdfDriverCreate` and -`WdfDeviceCreate`. +Its INF uses a private CatHub proof-of-concept class. The driver registers `application` and +`daemon` reference names under private interface +`{0084BDDE-9F40-4A6A-AF84-0F4E46B70901}` and transfers raw bytes between them. Keeping the device non-serial prevents an incomplete driver from appearing usable to N1MM or another station application. @@ -59,9 +61,9 @@ The source remains upstream; CatHub does not copy the C implementation. | Area | VirtualSerial2 behavior | CatHub PoC state | |---|---|---| | Driver | `DriverEntry`, `EVT_WDF_DRIVER_DEVICE_ADD` | Entry and device-add skeleton | -| Device | Device context and cleanup callback | Planned with endpoint state | -| Default queue | Parallel read, write, and device-control callbacks | Planned | -| Pending reads | Manual queue | Planned with bounded buffering and cancellation | +| Device | Device context and cleanup callback | Cleanup implemented; typed per-device context remains planned | +| Default queue | Parallel read, write, and device-control callbacks | Sequential proof-of-concept read/write queue implemented | +| Pending reads | Manual queue | Two manual queues with cancellation and disconnect draining implemented | | Pending event wait | Separate manual queue | Planned, one outstanding wait policy required | | Cleanup | Device cleanup releases COM mapping | Planned with daemon detach and fail-safe revocation | @@ -98,24 +100,32 @@ Those are CatHub requirements, not behaviors supplied by VirtualSerial2. ## Unsafe and FFI inventory -The initial unsafe surface is one module, `src/interop.rs`: +The unsafe surface remains isolated in one module, `src/interop.rs`: - exported `DriverEntry` - `WdfDriverCreate` - device-add callback and `WdfDeviceCreate` +- file create and cleanup callbacks +- private device-interface registration +- default and manual queue creation +- request forwarding, retrieval, cancellation, and completion +- conversion of WDF-owned request buffers and file names to bounded Rust slices - unload callback registration Every ABI entry catches panics. -There are no raw request buffers, context casts, ownership transfers, or asynchronous request races -yet. -Each of those categories must be added to this inventory when introduced. +The raw slices never outlive their WDF request or file-object callback. Safe Rust owns the bounded +byte buffers, exclusive-handle state, and application session sequence. The current process-global +state supports the proof-of-concept's single root-enumerated device only; it must become typed +per-device context before multi-device support. ## Gates before the INF becomes a Ports-class package 1. Restore the pinned WDK package in the isolated development environment. 2. Build and package this proof-of-concept-class driver with warnings treated as errors. -3. Add device and queue contexts with typed accessors. -4. Implement bounded application read/write queues, cancellation, cleanup, and timeout state. +3. Replace the single-device proof-of-concept state with device and queue contexts using typed + accessors. +4. Extend the implemented bounded read/write, cancellation, and cleanup behavior with serial + timeout state. 5. Register `GUID_DEVINTERFACE_COMPORT` and a private CatHub interface. 6. Add the COM mapping only after restart and removal are deterministic. 7. Install only on the isolated test target with development-signing policy documented. diff --git a/drivers/cathub-virtual-serial-umdf/README.md b/drivers/cathub-virtual-serial-umdf/README.md index ea6ca75..48d8797 100644 --- a/drivers/cathub-virtual-serial-umdf/README.md +++ b/drivers/cathub-virtual-serial-umdf/README.md @@ -4,9 +4,10 @@ This isolated Cargo package proves the first build and ABI boundary for issue #6 It is not part of the normal CatHub workspace because `windows-drivers-rs` supports one WDK configuration per Cargo build graph. -The current driver creates a private proof-of-concept-class WDF device only. -It does not register `GUID_DEVINTERFACE_COMPORT`, claim a COM number, or expose the private CatHub -interface. +The current driver creates a private proof-of-concept-class WDF device and registers two +reference-named instances of private interface +`{0084BDDE-9F40-4A6A-AF84-0F4E46B70901}`: `application` and `daemon`. +It does not register `GUID_DEVINTERFACE_COMPORT` or claim a COM number. Do not install it on the working station. ## Pinned inputs @@ -70,6 +71,16 @@ All Windows and WDF calls live in `src/interop.rs`. Every exported or registered callback catches Rust panics before they can unwind into WDF. The driver contains no kernel-mode CatHub code and no C or C++ shim. -The next implementation step is the application COM interface and its bounded read/write queues. -That work must land with cancellation and cleanup behavior; the INF stays in the Sample class until -those behaviors exist. +The proof-of-concept data plane currently provides: + +- one exclusive handle for each reference name; +- independent 64 KiB bounded queues in both directions; +- all-or-nothing writes, with overflow reported instead of truncation; +- cancelable reads held in WDF manual queues when no data is available; and +- fail-closed cleanup that clears buffered bytes and completes both sides' pending reads when + either handle disconnects, with new reads and writes rejected until both peers reconnect. + +This is deliberately a single-device raw-byte proof of concept. It does not yet decode the shared +private framing contract, implement serial timeouts or controls, restrict the daemon interface to a +service SID, or expose a real application COM port. The INF stays in the private proof-of-concept +class until those behaviors are implemented and verified on an isolated driver-development target. diff --git a/drivers/cathub-virtual-serial-umdf/src/data_plane.rs b/drivers/cathub-virtual-serial-umdf/src/data_plane.rs new file mode 100644 index 0000000..8afdf8d --- /dev/null +++ b/drivers/cathub-virtual-serial-umdf/src/data_plane.rs @@ -0,0 +1,320 @@ +//! Safe, bounded byte transport between the two proof-of-concept handles. + +use std::collections::VecDeque; + +/// Maximum bytes retained in each direction. +pub const DEFAULT_BUFFER_CAPACITY: usize = 64 * 1024; + +/// Side of the proof-of-concept device channel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChannelRole { + /// Handle that stands in for the future application COM port. + Application, + /// Private handle owned by the CatHub-side test process. + Daemon, +} + +impl ChannelRole { + /// Return the role at the other end of this channel. + #[must_use] + pub const fn peer(self) -> Self { + match self { + Self::Application => Self::Daemon, + Self::Daemon => Self::Application, + } + } +} + +/// Rejected data-plane operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DataPlaneError { + /// This role already has an open handle. + AlreadyOpen(ChannelRole), + /// The role does not currently have an open handle. + NotOpen(ChannelRole), + /// The peer must attach before bytes can be accepted. + PeerNotOpen(ChannelRole), + /// The entire write would exceed the bounded directional buffer. + BufferFull { + /// Number of bytes requested by the caller. + requested: usize, + /// Number of bytes that could currently be accepted. + available: usize, + }, +} + +/// One endpoint's in-memory proof-of-concept transport state. +#[derive(Debug)] +pub struct EndpointDataPlane { + application_open: bool, + daemon_open: bool, + session_sequence: u64, + application_to_daemon: BoundedByteQueue, + daemon_to_application: BoundedByteQueue, +} + +impl EndpointDataPlane { + /// Create a data plane with equal limits in both directions. + #[must_use] + pub fn new(capacity: usize) -> Self { + Self { + application_open: false, + daemon_open: false, + session_sequence: 0, + application_to_daemon: BoundedByteQueue::new(capacity), + daemon_to_application: BoundedByteQueue::new(capacity), + } + } + + /// Open one exclusive role and return the current application session sequence. + /// + /// # Errors + /// + /// Returns [`DataPlaneError::AlreadyOpen`] if the role is already occupied. + pub fn open(&mut self, role: ChannelRole) -> Result { + let is_open = match role { + ChannelRole::Application => &mut self.application_open, + ChannelRole::Daemon => &mut self.daemon_open, + }; + if *is_open { + return Err(DataPlaneError::AlreadyOpen(role)); + } + *is_open = true; + if role == ChannelRole::Application { + self.session_sequence = self.session_sequence.wrapping_add(1).max(1); + } + Ok(self.session_sequence) + } + + /// Close one role and discard all bytes in both directions. + pub fn close(&mut self, role: ChannelRole) { + match role { + ChannelRole::Application => self.application_open = false, + ChannelRole::Daemon => self.daemon_open = false, + } + self.application_to_daemon.clear(); + self.daemon_to_application.clear(); + } + + /// Return bytes available for the supplied role to read. + /// + /// # Errors + /// + /// Returns an error if either side is closed. + pub fn available_to_read(&self, role: ChannelRole) -> Result { + self.require_open(role)?; + self.require_peer_open(role)?; + Ok(self.incoming(role).len()) + } + + /// Atomically append one entire write to the peer's bounded buffer. + /// + /// # Errors + /// + /// Returns an error if either side is closed or the complete write does not fit. + pub fn write(&mut self, role: ChannelRole, bytes: &[u8]) -> Result { + self.require_open(role)?; + self.require_peer_open(role)?; + self.outgoing_mut(role).try_push(bytes)?; + Ok(bytes.len()) + } + + /// Remove up to `output.len()` bytes waiting for the supplied role. + /// + /// # Errors + /// + /// Returns an error if either side is closed. + pub fn read(&mut self, role: ChannelRole, output: &mut [u8]) -> Result { + self.require_open(role)?; + self.require_peer_open(role)?; + Ok(self.incoming_mut(role).pop_into(output)) + } + + const fn require_open(&self, role: ChannelRole) -> Result<(), DataPlaneError> { + let open = match role { + ChannelRole::Application => self.application_open, + ChannelRole::Daemon => self.daemon_open, + }; + if open { + Ok(()) + } else { + Err(DataPlaneError::NotOpen(role)) + } + } + + fn require_peer_open(&self, role: ChannelRole) -> Result<(), DataPlaneError> { + self.require_open(role.peer()) + .map_err(|_| DataPlaneError::PeerNotOpen(role.peer())) + } + + const fn incoming(&self, role: ChannelRole) -> &BoundedByteQueue { + match role { + ChannelRole::Application => &self.daemon_to_application, + ChannelRole::Daemon => &self.application_to_daemon, + } + } + + const fn incoming_mut(&mut self, role: ChannelRole) -> &mut BoundedByteQueue { + match role { + ChannelRole::Application => &mut self.daemon_to_application, + ChannelRole::Daemon => &mut self.application_to_daemon, + } + } + + const fn outgoing_mut(&mut self, role: ChannelRole) -> &mut BoundedByteQueue { + match role { + ChannelRole::Application => &mut self.application_to_daemon, + ChannelRole::Daemon => &mut self.daemon_to_application, + } + } +} + +#[derive(Debug)] +struct BoundedByteQueue { + bytes: VecDeque, + capacity: usize, +} + +impl BoundedByteQueue { + fn new(capacity: usize) -> Self { + Self { + bytes: VecDeque::with_capacity(capacity), + capacity, + } + } + + fn len(&self) -> usize { + self.bytes.len() + } + + fn try_push(&mut self, bytes: &[u8]) -> Result<(), DataPlaneError> { + let available = self.capacity.saturating_sub(self.bytes.len()); + if bytes.len() > available { + return Err(DataPlaneError::BufferFull { + requested: bytes.len(), + available, + }); + } + self.bytes.extend(bytes.iter().copied()); + Ok(()) + } + + fn pop_into(&mut self, output: &mut [u8]) -> usize { + let count = output.len().min(self.bytes.len()); + for (slot, byte) in output.iter_mut().zip(self.bytes.drain(..count)) { + *slot = byte; + } + count + } + + fn clear(&mut self) { + self.bytes.clear(); + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + fn connected(capacity: usize) -> EndpointDataPlane { + let mut plane = EndpointDataPlane::new(capacity); + plane.open(ChannelRole::Application).expect("application"); + plane.open(ChannelRole::Daemon).expect("daemon"); + plane + } + + #[test] + fn roles_are_exclusive() { + let mut plane = EndpointDataPlane::new(8); + assert_eq!(plane.open(ChannelRole::Application), Ok(1)); + assert_eq!( + plane.open(ChannelRole::Application), + Err(DataPlaneError::AlreadyOpen(ChannelRole::Application)) + ); + } + + #[test] + fn writes_fail_closed_until_peer_attaches() { + let mut plane = EndpointDataPlane::new(8); + plane.open(ChannelRole::Application).expect("application"); + assert_eq!( + plane.write(ChannelRole::Application, b"FA;"), + Err(DataPlaneError::PeerNotOpen(ChannelRole::Daemon)) + ); + } + + #[test] + fn directions_do_not_leak_into_each_other() { + let mut plane = connected(16); + plane + .write(ChannelRole::Application, b"application") + .expect("application write"); + plane + .write(ChannelRole::Daemon, b"daemon") + .expect("daemon write"); + + let mut application = [0; 16]; + let mut daemon = [0; 16]; + let application_len = plane + .read(ChannelRole::Application, &mut application) + .expect("application read"); + let daemon_len = plane + .read(ChannelRole::Daemon, &mut daemon) + .expect("daemon read"); + + assert_eq!(&application[..application_len], b"daemon"); + assert_eq!(&daemon[..daemon_len], b"application"); + } + + #[test] + fn overflowing_write_is_rejected_without_partial_data() { + let mut plane = connected(4); + assert_eq!( + plane.write(ChannelRole::Application, b"12345"), + Err(DataPlaneError::BufferFull { + requested: 5, + available: 4, + }) + ); + assert_eq!(plane.available_to_read(ChannelRole::Daemon), Ok(0)); + } + + #[test] + fn partial_read_preserves_remaining_bytes() { + let mut plane = connected(8); + plane + .write(ChannelRole::Application, b"123456") + .expect("write"); + let mut first = [0; 2]; + let mut second = [0; 8]; + + assert_eq!(plane.read(ChannelRole::Daemon, &mut first), Ok(2)); + assert_eq!(&first, b"12"); + assert_eq!(plane.read(ChannelRole::Daemon, &mut second), Ok(4)); + assert_eq!(&second[..4], b"3456"); + } + + #[test] + fn either_close_discards_both_directions_and_advances_next_session() { + let mut plane = connected(8); + plane + .write(ChannelRole::Application, b"app") + .expect("application write"); + plane + .write(ChannelRole::Daemon, b"daemon") + .expect("daemon write"); + + plane.close(ChannelRole::Application); + assert_eq!( + plane.available_to_read(ChannelRole::Application), + Err(DataPlaneError::NotOpen(ChannelRole::Application)) + ); + assert_eq!( + plane.available_to_read(ChannelRole::Daemon), + Err(DataPlaneError::PeerNotOpen(ChannelRole::Application)) + ); + assert_eq!(plane.open(ChannelRole::Application), Ok(2)); + assert_eq!(plane.available_to_read(ChannelRole::Application), Ok(0)); + } +} diff --git a/drivers/cathub-virtual-serial-umdf/src/interop.rs b/drivers/cathub-virtual-serial-umdf/src/interop.rs index c47a476..2a04402 100644 --- a/drivers/cathub-virtual-serial-umdf/src/interop.rs +++ b/drivers/cathub-virtual-serial-umdf/src/interop.rs @@ -1,15 +1,122 @@ //! Auditable Windows/WDF FFI boundary. -use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::{ + mem::size_of, + panic::{AssertUnwindSafe, catch_unwind}, + ptr, slice, + sync::{ + LazyLock, Mutex, MutexGuard, + atomic::{AtomicPtr, Ordering}, + }, +}; use wdk::println; use wdk_sys::{ - NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, ULONG, WDF_DRIVER_CONFIG, WDF_NO_HANDLE, - WDF_NO_OBJECT_ATTRIBUTES, WDFDEVICE, WDFDEVICE_INIT, WDFDRIVER, + _WDF_FILEOBJECT_CLASS, _WDF_IO_QUEUE_DISPATCH_TYPE, _WDF_TRI_STATE, BOOLEAN, GUID, NTSTATUS, + PCUNICODE_STRING, PDRIVER_OBJECT, PVOID, ULONG, ULONG_PTR, UNICODE_STRING, WDF_DRIVER_CONFIG, + WDF_FILEOBJECT_CONFIG, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDFDEVICE, + WDFDEVICE_INIT, WDFDRIVER, WDFFILEOBJECT, WDFQUEUE, WDFQUEUE__, WDFREQUEST, call_unsafe_wdf_function_binding, }; +use crate::data_plane::{ChannelRole, DEFAULT_BUFFER_CAPACITY, DataPlaneError, EndpointDataPlane}; + +const STATUS_SUCCESS: NTSTATUS = 0; const STATUS_UNSUCCESSFUL: NTSTATUS = -1_073_741_823; +const STATUS_INVALID_DEVICE_REQUEST: NTSTATUS = -1_073_741_808; +const STATUS_OBJECT_NAME_INVALID: NTSTATUS = -1_073_741_773; +const STATUS_SHARING_VIOLATION: NTSTATUS = -1_073_741_757; +const STATUS_DEVICE_NOT_CONNECTED: NTSTATUS = -1_073_741_667; +const STATUS_CANCELLED: NTSTATUS = -1_073_741_536; +const STATUS_BUFFER_OVERFLOW: NTSTATUS = -2_147_483_643; +const TRUE: BOOLEAN = 1; + +const APPLICATION_REFERENCE: [u16; 12] = [97, 112, 112, 108, 105, 99, 97, 116, 105, 111, 110, 0]; +const DAEMON_REFERENCE: [u16; 7] = [100, 97, 101, 109, 111, 110, 0]; + +/// Private proof-of-concept interface, `{0084BDDE-9F40-4A6A-AF84-0F4E46B70901}`. +static CATHUB_POC_INTERFACE_GUID: GUID = GUID { + Data1: 0x0084_BDDE, + Data2: 0x9F40, + Data3: 0x4A6A, + Data4: [0xAF, 0x84, 0x0F, 0x4E, 0x46, 0xB7, 0x09, 0x01], +}; + +static APPLICATION_READS: AtomicPtr = AtomicPtr::new(ptr::null_mut()); +static DAEMON_READS: AtomicPtr = AtomicPtr::new(ptr::null_mut()); +static CHANNEL: LazyLock> = LazyLock::new(|| Mutex::new(ChannelState::new())); + +struct ChannelState { + plane: EndpointDataPlane, + application_owner: usize, + daemon_owner: usize, +} + +impl ChannelState { + fn new() -> Self { + Self { + plane: EndpointDataPlane::new(DEFAULT_BUFFER_CAPACITY), + application_owner: 0, + daemon_owner: 0, + } + } + + fn open(&mut self, role: ChannelRole, file: WDFFILEOBJECT) -> Result { + let session = self.plane.open(role)?; + *self.owner_mut(role) = file.addr(); + Ok(session) + } + + fn close_if_owner(&mut self, role: ChannelRole, file: WDFFILEOBJECT) -> bool { + let owner = self.owner_mut(role); + if *owner != file.addr() { + return false; + } + *owner = 0; + self.plane.close(role); + true + } + + fn owns(&self, role: ChannelRole, file: WDFFILEOBJECT) -> bool { + let owner = match role { + ChannelRole::Application => self.application_owner, + ChannelRole::Daemon => self.daemon_owner, + }; + owner != 0 && owner == file.addr() + } + + const fn owner_mut(&mut self, role: ChannelRole) -> &mut usize { + match role { + ChannelRole::Application => &mut self.application_owner, + ChannelRole::Daemon => &mut self.daemon_owner, + } + } +} + +#[derive(Clone, Copy)] +enum RequestDisposition { + Complete { + status: NTSTATUS, + information: usize, + }, + Pending, +} + +impl RequestDisposition { + const fn success(information: usize) -> Self { + Self::Complete { + status: STATUS_SUCCESS, + information, + } + } + + const fn error(status: NTSTATUS) -> Self { + Self::Complete { + status, + information: 0, + } + } +} /// WDF driver entry point. /// @@ -32,58 +139,503 @@ pub unsafe extern "system" fn driver_entry( unsafe fn driver_entry_inner(driver: PDRIVER_OBJECT, registry_path: PCUNICODE_STRING) -> NTSTATUS { println!("CatHub UMDF proof-of-concept DriverEntry"); - - let config_size = - u32::try_from(core::mem::size_of::()).unwrap_or(ULONG::MAX); let mut driver_config = WDF_DRIVER_CONFIG { - Size: config_size, + Size: struct_size::(), EvtDriverDeviceAdd: Some(evt_driver_device_add), EvtDriverUnload: Some(evt_driver_unload), ..WDF_DRIVER_CONFIG::default() }; - let driver_attributes = WDF_NO_OBJECT_ATTRIBUTES; - let driver_handle_output = WDF_NO_HANDLE.cast::(); - - // SAFETY: WDF owns the two input pointers. The attributes and output handle may be null, - // and `driver_config` remains valid for the duration of the call. + // SAFETY: WDF owns both input pointers. Null attributes/output are allowed and the config + // remains valid for the synchronous call. unsafe { call_unsafe_wdf_function_binding!( WdfDriverCreate, driver, registry_path, - driver_attributes, + WDF_NO_OBJECT_ATTRIBUTES, &raw mut driver_config, - driver_handle_output, + WDF_NO_HANDLE.cast::(), ) } } -extern "C" fn evt_driver_device_add( +unsafe extern "C" fn evt_driver_device_add( _driver: WDFDRIVER, device_init: *mut WDFDEVICE_INIT, ) -> NTSTATUS { ffi_status(|| { - // SAFETY: WDF supplies a valid, exclusively owned device-init pointer to this callback. + // SAFETY: WDF supplies a valid, exclusively owned device-init pointer. unsafe { create_device(device_init) } }) } unsafe fn create_device(mut device_init: *mut WDFDEVICE_INIT) -> NTSTATUS { - let mut device: WDFDEVICE = WDF_NO_HANDLE.cast(); - - // SAFETY: WDF supplies `device_init`; this callback consumes it exactly once on success. - // Null object attributes are allowed and `device` is a valid output location. + let mut file_config = WDF_FILEOBJECT_CONFIG { + Size: struct_size::(), + EvtDeviceFileCreate: Some(evt_device_file_create), + EvtFileCleanup: Some(evt_file_cleanup), + AutoForwardCleanupClose: _WDF_TRI_STATE::WdfUseDefault, + FileObjectClass: _WDF_FILEOBJECT_CLASS::WdfFileObjectWdfCannotUseFsContexts, + ..WDF_FILEOBJECT_CONFIG::default() + }; + // SAFETY: WDF copies this configuration synchronously. Null file attributes are allowed. unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceInitSetFileObjectConfig, + device_init, + &raw mut file_config, + WDF_NO_OBJECT_ATTRIBUTES, + ); + } + + let mut device: WDFDEVICE = WDF_NO_HANDLE.cast(); + // SAFETY: This consumes WDF's device-init pointer exactly once on success. + let status = unsafe { call_unsafe_wdf_function_binding!( WdfDeviceCreate, &raw mut device_init, WDF_NO_OBJECT_ATTRIBUTES, &raw mut device, ) + }; + if !nt_success(status) { + return status; + } + // SAFETY: The WDF device was successfully created above. + let status = unsafe { configure_queues(device) }; + if !nt_success(status) { + return status; + } + // SAFETY: The device exists and registration consumes both counted strings synchronously. + unsafe { register_interfaces(device) } +} + +unsafe fn configure_queues(device: WDFDEVICE) -> NTSTATUS { + let mut application_reads = ptr::null_mut(); + // SAFETY: The device and output storage are valid for synchronous queue creation. + let status = unsafe { create_manual_read_queue(device, &raw mut application_reads) }; + if !nt_success(status) { + return status; + } + APPLICATION_READS.store(application_reads, Ordering::Release); + + let mut daemon_reads = ptr::null_mut(); + // SAFETY: The device and output storage are valid for synchronous queue creation. + let status = unsafe { create_manual_read_queue(device, &raw mut daemon_reads) }; + if !nt_success(status) { + return status; + } + DAEMON_READS.store(daemon_reads, Ordering::Release); + + let mut default_queue: WDFQUEUE = ptr::null_mut(); + let mut config = WDF_IO_QUEUE_CONFIG { + Size: struct_size::(), + DispatchType: _WDF_IO_QUEUE_DISPATCH_TYPE::WdfIoQueueDispatchSequential, + PowerManaged: _WDF_TRI_STATE::WdfUseDefault, + AllowZeroLengthRequests: TRUE, + DefaultQueue: TRUE, + EvtIoDefault: Some(evt_io_default), + EvtIoRead: Some(evt_io_read), + EvtIoWrite: Some(evt_io_write), + ..WDF_IO_QUEUE_CONFIG::default() + }; + // SAFETY: WDF copies the config; null attributes are allowed and output storage is valid. + unsafe { + call_unsafe_wdf_function_binding!( + WdfIoQueueCreate, + device, + &raw mut config, + WDF_NO_OBJECT_ATTRIBUTES, + &raw mut default_queue, + ) + } +} + +unsafe fn create_manual_read_queue(device: WDFDEVICE, queue: *mut WDFQUEUE) -> NTSTATUS { + let mut config = WDF_IO_QUEUE_CONFIG { + Size: struct_size::(), + DispatchType: _WDF_IO_QUEUE_DISPATCH_TYPE::WdfIoQueueDispatchManual, + PowerManaged: _WDF_TRI_STATE::WdfUseDefault, + EvtIoCanceledOnQueue: Some(evt_io_canceled_on_queue), + ..WDF_IO_QUEUE_CONFIG::default() + }; + // SAFETY: WDF copies the config; null attributes are allowed and `queue` is valid output. + unsafe { + call_unsafe_wdf_function_binding!( + WdfIoQueueCreate, + device, + &raw mut config, + WDF_NO_OBJECT_ATTRIBUTES, + queue, + ) + } +} + +unsafe fn register_interfaces(device: WDFDEVICE) -> NTSTATUS { + let application = unicode_string(&APPLICATION_REFERENCE); + // SAFETY: All pointers remain valid through this synchronous call. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceCreateDeviceInterface, + device, + &raw const CATHUB_POC_INTERFACE_GUID, + &raw const application, + ) + }; + if !nt_success(status) { + return status; + } + let daemon = unicode_string(&DAEMON_REFERENCE); + // SAFETY: All pointers remain valid through this synchronous call. + unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceCreateDeviceInterface, + device, + &raw const CATHUB_POC_INTERFACE_GUID, + &raw const daemon, + ) + } +} + +unsafe extern "C" fn evt_device_file_create( + _device: WDFDEVICE, + request: WDFREQUEST, + file: WDFFILEOBJECT, +) { + let disposition = catch_unwind(AssertUnwindSafe(|| { + // SAFETY: WDF supplies this file object to its create callback. + let Some(role) = (unsafe { role_from_file(file) }) else { + return RequestDisposition::error(STATUS_OBJECT_NAME_INVALID); + }; + let open_result = channel().open(role, file); + match open_result { + Ok(session) => { + println!("CatHub PoC {role:?} handle opened (session {session})"); + RequestDisposition::success(0) + } + Err(error) => RequestDisposition::error(status_for_error(error)), + } + })) + .unwrap_or_else(|_| RequestDisposition::error(STATUS_UNSUCCESSFUL)); + // SAFETY: WDF transfers ownership of the create request to this callback. + unsafe { finish_request(request, disposition) }; +} + +unsafe extern "C" fn evt_file_cleanup(file: WDFFILEOBJECT) { + ffi_void(|| { + // SAFETY: WDF supplies this file object to its cleanup callback. + let Some(role) = (unsafe { role_from_file(file) }) else { + return; + }; + if channel().close_if_owner(role, file) { + println!("CatHub PoC {role:?} handle cleaned up"); + // SAFETY: This handle refers to a manual queue owned by this device. + unsafe { drain_pending_reads(queue_for(ChannelRole::Application), STATUS_CANCELLED) }; + // SAFETY: This handle refers to a manual queue owned by this device. + unsafe { drain_pending_reads(queue_for(ChannelRole::Daemon), STATUS_CANCELLED) }; + } + }); +} + +unsafe extern "C" fn evt_io_read(_queue: WDFQUEUE, request: WDFREQUEST, length: usize) { + // SAFETY: WDF transfers the valid request to this read callback. + dispatch_request(request, || unsafe { handle_read(request, length) }); +} + +unsafe extern "C" fn evt_io_write(_queue: WDFQUEUE, request: WDFREQUEST, length: usize) { + // SAFETY: WDF transfers the valid request to this write callback. + dispatch_request(request, || unsafe { handle_write(request, length) }); +} + +unsafe extern "C" fn evt_io_default(_queue: WDFQUEUE, request: WDFREQUEST) { + ffi_void(|| { + // SAFETY: WDF transfers ownership of this unsupported request to the callback. + unsafe { complete_request(request, STATUS_INVALID_DEVICE_REQUEST, 0) }; + }); +} + +unsafe extern "C" fn evt_io_canceled_on_queue(_queue: WDFQUEUE, request: WDFREQUEST) { + ffi_void(|| { + // SAFETY: WDF transfers ownership of the canceled request to the callback. + unsafe { complete_request(request, STATUS_CANCELLED, 0) }; + }); +} + +unsafe fn handle_read(request: WDFREQUEST, length: usize) -> RequestDisposition { + // SAFETY: The invoking WDF callback owns this request. + let Some((role, file)) = (unsafe { request_identity(request) }) else { + return RequestDisposition::error(STATUS_OBJECT_NAME_INVALID); + }; + if !channel().owns(role, file) { + return RequestDisposition::error(STATUS_INVALID_DEVICE_REQUEST); + } + if length == 0 { + return RequestDisposition::success(0); + } + let available = channel().plane.available_to_read(role); + match available { + Ok(0) => { + let queue = queue_for(role); + if queue.is_null() { + return RequestDisposition::error(STATUS_UNSUCCESSFUL); + } + // SAFETY: The request is framework-owned and target is a valid manual queue. + let status = unsafe { + call_unsafe_wdf_function_binding!(WdfRequestForwardToIoQueue, request, queue) + }; + if nt_success(status) { + RequestDisposition::Pending + } else { + RequestDisposition::error(status) + } + } + // SAFETY: The invoking WDF callback still owns this request. + Ok(_) => unsafe { read_available(request, role, length) }, + Err(error) => RequestDisposition::error(status_for_error(error)), + } +} + +unsafe fn handle_write(request: WDFREQUEST, length: usize) -> RequestDisposition { + // SAFETY: The invoking WDF callback owns this request. + let Some((role, file)) = (unsafe { request_identity(request) }) else { + return RequestDisposition::error(STATUS_OBJECT_NAME_INVALID); + }; + if !channel().owns(role, file) { + return RequestDisposition::error(STATUS_INVALID_DEVICE_REQUEST); + } + if length == 0 { + return RequestDisposition::success(0); + } + let mut buffer: PVOID = ptr::null_mut(); + let mut buffer_length = 0; + // SAFETY: WDF owns the request and returns a buffer valid until completion. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfRequestRetrieveInputBuffer, + request, + 1, + &raw mut buffer, + &raw mut buffer_length, + ) + }; + if !nt_success(status) { + return RequestDisposition::error(status); + } + let usable = length.min(buffer_length); + // SAFETY: WDF returned at least `buffer_length` readable bytes. + let input = unsafe { slice::from_raw_parts(buffer.cast_const().cast::(), usable) }; + let write_result = channel().plane.write(role, input); + match write_result { + Ok(written) => { + // SAFETY: The peer's pending queue belongs to this device. + unsafe { service_pending_reads(role.peer()) }; + RequestDisposition::success(written) + } + Err(error) => RequestDisposition::error(status_for_error(error)), + } +} + +unsafe fn read_available( + request: WDFREQUEST, + role: ChannelRole, + requested: usize, +) -> RequestDisposition { + let mut buffer: PVOID = ptr::null_mut(); + let mut buffer_length = 0; + // SAFETY: WDF owns the request and returns a buffer valid until completion. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfRequestRetrieveOutputBuffer, + request, + 1, + &raw mut buffer, + &raw mut buffer_length, + ) + }; + if !nt_success(status) { + return RequestDisposition::error(status); + } + let usable = requested.min(buffer_length); + // SAFETY: WDF returned at least `buffer_length` writable bytes. + let output = unsafe { slice::from_raw_parts_mut(buffer.cast::(), usable) }; + let read_result = channel().plane.read(role, output); + match read_result { + Ok(read) => RequestDisposition::success(read), + Err(error) => RequestDisposition::error(status_for_error(error)), + } +} + +unsafe fn service_pending_reads(role: ChannelRole) { + let queue = queue_for(role); + loop { + if channel() + .plane + .available_to_read(role) + .map_or(true, |available| available == 0) + { + break; + } + let mut request: WDFREQUEST = ptr::null_mut(); + // SAFETY: Queue is a valid manual queue and request is valid output storage. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfIoQueueRetrieveNextRequest, + queue, + &raw mut request, + ) + }; + if !nt_success(status) { + break; + } + // SAFETY: Retrieval transfers the queued request to this driver. + let disposition = unsafe { read_available(request, role, usize::MAX) }; + // SAFETY: The retrieved request is no longer owned by the manual queue. + unsafe { finish_request(request, disposition) }; + } +} + +unsafe fn drain_pending_reads(queue: WDFQUEUE, status: NTSTATUS) { + if queue.is_null() { + return; + } + loop { + let mut request: WDFREQUEST = ptr::null_mut(); + // SAFETY: Queue is a valid manual queue and request is valid output storage. + let retrieved = unsafe { + call_unsafe_wdf_function_binding!( + WdfIoQueueRetrieveNextRequest, + queue, + &raw mut request, + ) + }; + if !nt_success(retrieved) { + break; + } + // SAFETY: Retrieval transfers ownership of the request to this driver. + unsafe { complete_request(request, status, 0) }; + } +} + +unsafe fn request_identity(request: WDFREQUEST) -> Option<(ChannelRole, WDFFILEOBJECT)> { + // SAFETY: Caller supplies a valid WDF request. + let file = unsafe { call_unsafe_wdf_function_binding!(WdfRequestGetFileObject, request) }; + if file.is_null() { + return None; } + // SAFETY: WDF associates this file object with the current request. + unsafe { role_from_file(file) }.map(|role| (role, file)) } -extern "C" fn evt_driver_unload(_driver: WDFDRIVER) { +unsafe fn role_from_file(file: WDFFILEOBJECT) -> Option { + // SAFETY: Caller supplies a valid WDF file object. + let name = unsafe { call_unsafe_wdf_function_binding!(WdfFileObjectGetFileName, file) }; + if name.is_null() { + return None; + } + // SAFETY: WDF returns a valid counted string for the file object's lifetime. + let name = unsafe { &*name }; + if name.Buffer.is_null() || name.Length % 2 != 0 { + return None; + } + // SAFETY: Length is bytes and was checked for complete UTF-16 code units. + let units = + unsafe { slice::from_raw_parts(name.Buffer.cast_const(), usize::from(name.Length / 2)) }; + role_from_name(units) +} + +fn role_from_name(name: &[u16]) -> Option { + let segment = name + .rsplit(|unit| *unit == u16::from(b'\\') || *unit == u16::from(b'/')) + .next()?; + if utf16_eq_ascii_case(segment, b"application") { + Some(ChannelRole::Application) + } else if utf16_eq_ascii_case(segment, b"daemon") { + Some(ChannelRole::Daemon) + } else { + None + } +} + +fn utf16_eq_ascii_case(units: &[u16], ascii: &[u8]) -> bool { + units.len() == ascii.len() + && units.iter().zip(ascii).all(|(left, right)| { + u8::try_from(*left).is_ok_and(|value| value.eq_ignore_ascii_case(right)) + }) +} + +fn dispatch_request(request: WDFREQUEST, action: impl FnOnce() -> RequestDisposition) { + let disposition = catch_unwind(AssertUnwindSafe(action)) + .unwrap_or_else(|_| RequestDisposition::error(STATUS_UNSUCCESSFUL)); + // SAFETY: Invoking callback owns the request unless disposition says it was forwarded. + unsafe { finish_request(request, disposition) }; +} + +unsafe fn finish_request(request: WDFREQUEST, disposition: RequestDisposition) { + if let RequestDisposition::Complete { + status, + information, + } = disposition + { + // SAFETY: Caller owns this request and completes it exactly once. + unsafe { complete_request(request, status, information) }; + } +} + +unsafe fn complete_request(request: WDFREQUEST, status: NTSTATUS, information: usize) { + let information = ULONG_PTR::try_from(information).unwrap_or(ULONG_PTR::MAX); + // SAFETY: Caller owns this request. WDF accepts status and byte count by value. + unsafe { + call_unsafe_wdf_function_binding!( + WdfRequestCompleteWithInformation, + request, + status, + information, + ); + } +} + +fn channel() -> MutexGuard<'static, ChannelState> { + CHANNEL + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn queue_for(role: ChannelRole) -> WDFQUEUE { + match role { + ChannelRole::Application => APPLICATION_READS.load(Ordering::Acquire), + ChannelRole::Daemon => DAEMON_READS.load(Ordering::Acquire), + } +} + +const fn nt_success(status: NTSTATUS) -> bool { + status >= 0 +} + +const fn status_for_error(error: DataPlaneError) -> NTSTATUS { + match error { + DataPlaneError::AlreadyOpen(_) => STATUS_SHARING_VIOLATION, + DataPlaneError::NotOpen(_) => STATUS_INVALID_DEVICE_REQUEST, + DataPlaneError::PeerNotOpen(_) => STATUS_DEVICE_NOT_CONNECTED, + DataPlaneError::BufferFull { .. } => STATUS_BUFFER_OVERFLOW, + } +} + +fn struct_size() -> ULONG { + u32::try_from(size_of::()).unwrap_or(ULONG::MAX) +} + +fn unicode_string(units_with_null: &[u16]) -> UNICODE_STRING { + let content_units = units_with_null.len().saturating_sub(1); + let length = content_units.saturating_mul(size_of::()); + let maximum_length = units_with_null.len().saturating_mul(size_of::()); + UNICODE_STRING { + Length: u16::try_from(length).unwrap_or(u16::MAX), + MaximumLength: u16::try_from(maximum_length).unwrap_or(u16::MAX), + Buffer: units_with_null.as_ptr().cast_mut(), + } +} + +unsafe extern "C" fn evt_driver_unload(_driver: WDFDRIVER) { ffi_void(|| println!("CatHub UMDF proof-of-concept unloaded")); } @@ -109,4 +661,15 @@ mod tests { fn successful_status_crosses_boundary() { assert_eq!(ffi_status(|| 0), 0); } + + #[test] + fn role_is_parsed_from_reference_name() { + let application: Vec = "\\application".encode_utf16().collect(); + let daemon: Vec = "DAEMON".encode_utf16().collect(); + let unknown: Vec = "control".encode_utf16().collect(); + + assert_eq!(role_from_name(&application), Some(ChannelRole::Application)); + assert_eq!(role_from_name(&daemon), Some(ChannelRole::Daemon)); + assert_eq!(role_from_name(&unknown), None); + } } diff --git a/drivers/cathub-virtual-serial-umdf/src/lib.rs b/drivers/cathub-virtual-serial-umdf/src/lib.rs index c3671a9..98fa6a0 100644 --- a/drivers/cathub-virtual-serial-umdf/src/lib.rs +++ b/drivers/cathub-virtual-serial-umdf/src/lib.rs @@ -3,9 +3,11 @@ //! Pure Rust UMDF 2 proof of concept for `CatHub` virtual serial endpoints. //! -//! The current milestone only creates a WDF device. It deliberately does not -//! register a COM port or a private `CatHub` interface until their queues and -//! failure behavior are implemented. +//! The current milestone creates a private, reference-named application/daemon +//! interface with bounded bidirectional queues. It deliberately does not yet +//! register a public COM port. +#[cfg_attr(test, allow(dead_code))] +mod data_plane; #[cfg_attr(test, allow(dead_code))] mod interop; From 939d7ef941d01a065f4cf7646fb014975337287d Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 15:02:49 -0700 Subject: [PATCH 04/39] Move UMDF state into typed device context --- docs/design/virtual-serial-umdf-poc.md | 13 +- drivers/cathub-virtual-serial-umdf/README.md | 11 +- .../cathub-virtual-serial-umdf/src/interop.rs | 281 +++++++++++++++--- 3 files changed, 248 insertions(+), 57 deletions(-) diff --git a/docs/design/virtual-serial-umdf-poc.md b/docs/design/virtual-serial-umdf-poc.md index bdf4874..bba88e3 100644 --- a/docs/design/virtual-serial-umdf-poc.md +++ b/docs/design/virtual-serial-umdf-poc.md @@ -61,7 +61,7 @@ The source remains upstream; CatHub does not copy the C implementation. | Area | VirtualSerial2 behavior | CatHub PoC state | |---|---|---| | Driver | `DriverEntry`, `EVT_WDF_DRIVER_DEVICE_ADD` | Entry and device-add skeleton | -| Device | Device context and cleanup callback | Cleanup implemented; typed per-device context remains planned | +| Device | Device context and cleanup callback | Typed per-device context and destroy cleanup implemented | | Default queue | Parallel read, write, and device-control callbacks | Sequential proof-of-concept read/write queue implemented | | Pending reads | Manual queue | Two manual queues with cancellation and disconnect draining implemented | | Pending event wait | Separate manual queue | Planned, one outstanding wait policy required | @@ -108,22 +108,23 @@ The unsafe surface remains isolated in one module, `src/interop.rs`: - file create and cleanup callbacks - private device-interface registration - default and manual queue creation +- typed device-context registration, lookup, and destroy cleanup - request forwarding, retrieval, cancellation, and completion - conversion of WDF-owned request buffers and file names to bounded Rust slices - unload callback registration Every ABI entry catches panics. The raw slices never outlive their WDF request or file-object callback. Safe Rust owns the bounded -byte buffers, exclusive-handle state, and application session sequence. The current process-global -state supports the proof-of-concept's single root-enumerated device only; it must become typed -per-device context before multi-device support. +byte buffers, exclusive-handle state, application session sequence, and pending-read queue handles. +Each device owns those values through typed WDF context; file and queue callbacks resolve their +parent device before accessing the state. ## Gates before the INF becomes a Ports-class package 1. Restore the pinned WDK package in the isolated development environment. 2. Build and package this proof-of-concept-class driver with warnings treated as errors. -3. Replace the single-device proof-of-concept state with device and queue contexts using typed - accessors. +3. Keep endpoint state and queue handles in typed per-device context, resolving it from file and + queue callbacks. 4. Extend the implemented bounded read/write, cancellation, and cleanup behavior with serial timeout state. 5. Register `GUID_DEVINTERFACE_COMPORT` and a private CatHub interface. diff --git a/drivers/cathub-virtual-serial-umdf/README.md b/drivers/cathub-virtual-serial-umdf/README.md index 48d8797..a0e3df2 100644 --- a/drivers/cathub-virtual-serial-umdf/README.md +++ b/drivers/cathub-virtual-serial-umdf/README.md @@ -80,7 +80,10 @@ The proof-of-concept data plane currently provides: - fail-closed cleanup that clears buffered bytes and completes both sides' pending reads when either handle disconnects, with new reads and writes rejected until both peers reconnect. -This is deliberately a single-device raw-byte proof of concept. It does not yet decode the shared -private framing contract, implement serial timeouts or controls, restrict the daemon interface to a -service SID, or expose a real application COM port. The INF stays in the private proof-of-concept -class until those behaviors are implemented and verified on an isolated driver-development target. +Each WDF device owns its transport state and pending-read queues through typed object context, and +the context's destroy callback releases the Rust-owned state during device teardown. + +This remains a raw-byte proof of concept. It does not yet decode the shared private framing +contract, implement serial timeouts or controls, restrict the daemon interface to a service SID, or +expose a real application COM port. The INF stays in the private proof-of-concept class until those +behaviors are implemented and verified on an isolated driver-development target. diff --git a/drivers/cathub-virtual-serial-umdf/src/interop.rs b/drivers/cathub-virtual-serial-umdf/src/interop.rs index 2a04402..6500786 100644 --- a/drivers/cathub-virtual-serial-umdf/src/interop.rs +++ b/drivers/cathub-virtual-serial-umdf/src/interop.rs @@ -1,22 +1,24 @@ //! Auditable Windows/WDF FFI boundary. use std::{ + ffi::c_void, mem::size_of, panic::{AssertUnwindSafe, catch_unwind}, ptr, slice, sync::{ - LazyLock, Mutex, MutexGuard, + Mutex, MutexGuard, atomic::{AtomicPtr, Ordering}, }, }; use wdk::println; use wdk_sys::{ - _WDF_FILEOBJECT_CLASS, _WDF_IO_QUEUE_DISPATCH_TYPE, _WDF_TRI_STATE, BOOLEAN, GUID, NTSTATUS, - PCUNICODE_STRING, PDRIVER_OBJECT, PVOID, ULONG, ULONG_PTR, UNICODE_STRING, WDF_DRIVER_CONFIG, - WDF_FILEOBJECT_CONFIG, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDFDEVICE, - WDFDEVICE_INIT, WDFDRIVER, WDFFILEOBJECT, WDFQUEUE, WDFQUEUE__, WDFREQUEST, - call_unsafe_wdf_function_binding, + _WDF_EXECUTION_LEVEL, _WDF_FILEOBJECT_CLASS, _WDF_IO_QUEUE_DISPATCH_TYPE, + _WDF_SYNCHRONIZATION_SCOPE, _WDF_TRI_STATE, BOOLEAN, GUID, NTSTATUS, PCUNICODE_STRING, + PDRIVER_OBJECT, PVOID, ULONG, ULONG_PTR, UNICODE_STRING, WDF_DRIVER_CONFIG, + WDF_FILEOBJECT_CONFIG, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, + WDF_OBJECT_ATTRIBUTES, WDF_OBJECT_CONTEXT_TYPE_INFO, WDFDEVICE, WDFDEVICE_INIT, WDFDRIVER, + WDFFILEOBJECT, WDFOBJECT, WDFQUEUE, WDFQUEUE__, WDFREQUEST, call_unsafe_wdf_function_binding, }; use crate::data_plane::{ChannelRole, DEFAULT_BUFFER_CAPACITY, DataPlaneError, EndpointDataPlane}; @@ -42,9 +44,47 @@ static CATHUB_POC_INTERFACE_GUID: GUID = GUID { Data4: [0xAF, 0x84, 0x0F, 0x4E, 0x46, 0xB7, 0x09, 0x01], }; -static APPLICATION_READS: AtomicPtr = AtomicPtr::new(ptr::null_mut()); -static DAEMON_READS: AtomicPtr = AtomicPtr::new(ptr::null_mut()); -static CHANNEL: LazyLock> = LazyLock::new(|| Mutex::new(ChannelState::new())); +#[repr(C)] +struct DeviceContext { + state: *mut DeviceState, +} + +struct DeviceState { + channel: Mutex, + application_reads: AtomicPtr, + daemon_reads: AtomicPtr, +} + +impl DeviceState { + fn new() -> Self { + Self { + channel: Mutex::new(ChannelState::new()), + application_reads: AtomicPtr::new(ptr::null_mut()), + daemon_reads: AtomicPtr::new(ptr::null_mut()), + } + } + + fn channel(&self) -> MutexGuard<'_, ChannelState> { + self.channel + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn queue_for(&self, role: ChannelRole) -> WDFQUEUE { + match role { + ChannelRole::Application => self.application_reads.load(Ordering::Acquire), + ChannelRole::Daemon => self.daemon_reads.load(Ordering::Acquire), + } + } +} + +static mut DEVICE_CONTEXT_TYPE_INFO: WDF_OBJECT_CONTEXT_TYPE_INFO = WDF_OBJECT_CONTEXT_TYPE_INFO { + Size: 0, + ContextName: c"CatHubDeviceContext".as_ptr(), + ContextSize: size_of::(), + UniqueType: ptr::null(), + EvtDriverGetUniqueContextType: None, +}; struct ChannelState { plane: EndpointDataPlane, @@ -139,6 +179,8 @@ pub unsafe extern "system" fn driver_entry( unsafe fn driver_entry_inner(driver: PDRIVER_OBJECT, registry_path: PCUNICODE_STRING) -> NTSTATUS { println!("CatHub UMDF proof-of-concept DriverEntry"); + // SAFETY: DriverEntry runs before WDF can create device objects or invoke callbacks. + unsafe { initialize_device_context_type() }; let mut driver_config = WDF_DRIVER_CONFIG { Size: struct_size::(), EvtDriverDeviceAdd: Some(evt_driver_device_add), @@ -188,21 +230,38 @@ unsafe fn create_device(mut device_init: *mut WDFDEVICE_INIT) -> NTSTATUS { ); } + let mut device_attributes = WDF_OBJECT_ATTRIBUTES { + Size: struct_size::(), + EvtDestroyCallback: Some(evt_device_context_destroy), + ExecutionLevel: _WDF_EXECUTION_LEVEL::WdfExecutionLevelPassive, + SynchronizationScope: _WDF_SYNCHRONIZATION_SCOPE::WdfSynchronizationScopeNone, + ContextTypeInfo: device_context_type_info(), + ..WDF_OBJECT_ATTRIBUTES::default() + }; let mut device: WDFDEVICE = WDF_NO_HANDLE.cast(); // SAFETY: This consumes WDF's device-init pointer exactly once on success. let status = unsafe { call_unsafe_wdf_function_binding!( WdfDeviceCreate, &raw mut device_init, - WDF_NO_OBJECT_ATTRIBUTES, + &raw mut device_attributes, &raw mut device, ) }; if !nt_success(status) { return status; } + // SAFETY: Device creation allocated and zeroed the registered context space. + let Some(context) = (unsafe { device_context(device) }) else { + return STATUS_UNSUCCESSFUL; + }; + let state = Box::into_raw(Box::new(DeviceState::new())); + // SAFETY: The context is exclusively initialized before queues or interfaces publish device. + unsafe { (*context).state = state }; + // SAFETY: `state` remains owned by the WDF device context until its destroy callback. + let state = unsafe { &*state }; // SAFETY: The WDF device was successfully created above. - let status = unsafe { configure_queues(device) }; + let status = unsafe { configure_queues(device, state) }; if !nt_success(status) { return status; } @@ -210,14 +269,16 @@ unsafe fn create_device(mut device_init: *mut WDFDEVICE_INIT) -> NTSTATUS { unsafe { register_interfaces(device) } } -unsafe fn configure_queues(device: WDFDEVICE) -> NTSTATUS { +unsafe fn configure_queues(device: WDFDEVICE, state: &DeviceState) -> NTSTATUS { let mut application_reads = ptr::null_mut(); // SAFETY: The device and output storage are valid for synchronous queue creation. let status = unsafe { create_manual_read_queue(device, &raw mut application_reads) }; if !nt_success(status) { return status; } - APPLICATION_READS.store(application_reads, Ordering::Release); + state + .application_reads + .store(application_reads, Ordering::Release); let mut daemon_reads = ptr::null_mut(); // SAFETY: The device and output storage are valid for synchronous queue creation. @@ -225,7 +286,7 @@ unsafe fn configure_queues(device: WDFDEVICE) -> NTSTATUS { if !nt_success(status) { return status; } - DAEMON_READS.store(daemon_reads, Ordering::Release); + state.daemon_reads.store(daemon_reads, Ordering::Release); let mut default_queue: WDFQUEUE = ptr::null_mut(); let mut config = WDF_IO_QUEUE_CONFIG { @@ -298,16 +359,20 @@ unsafe fn register_interfaces(device: WDFDEVICE) -> NTSTATUS { } unsafe extern "C" fn evt_device_file_create( - _device: WDFDEVICE, + device: WDFDEVICE, request: WDFREQUEST, file: WDFFILEOBJECT, ) { let disposition = catch_unwind(AssertUnwindSafe(|| { + // SAFETY: WDF keeps the device and its context alive for this callback. + let Some(state) = (unsafe { device_state(device) }) else { + return RequestDisposition::error(STATUS_UNSUCCESSFUL); + }; // SAFETY: WDF supplies this file object to its create callback. let Some(role) = (unsafe { role_from_file(file) }) else { return RequestDisposition::error(STATUS_OBJECT_NAME_INVALID); }; - let open_result = channel().open(role, file); + let open_result = state.channel().open(role, file); match open_result { Ok(session) => { println!("CatHub PoC {role:?} handle opened (session {session})"); @@ -323,28 +388,46 @@ unsafe extern "C" fn evt_device_file_create( unsafe extern "C" fn evt_file_cleanup(file: WDFFILEOBJECT) { ffi_void(|| { + // SAFETY: WDF keeps the file's parent device alive for this cleanup callback. + let Some(state) = (unsafe { device_state_from_file(file) }) else { + return; + }; // SAFETY: WDF supplies this file object to its cleanup callback. let Some(role) = (unsafe { role_from_file(file) }) else { return; }; - if channel().close_if_owner(role, file) { + if state.channel().close_if_owner(role, file) { println!("CatHub PoC {role:?} handle cleaned up"); // SAFETY: This handle refers to a manual queue owned by this device. - unsafe { drain_pending_reads(queue_for(ChannelRole::Application), STATUS_CANCELLED) }; + unsafe { + drain_pending_reads(state.queue_for(ChannelRole::Application), STATUS_CANCELLED); + }; // SAFETY: This handle refers to a manual queue owned by this device. - unsafe { drain_pending_reads(queue_for(ChannelRole::Daemon), STATUS_CANCELLED) }; + unsafe { drain_pending_reads(state.queue_for(ChannelRole::Daemon), STATUS_CANCELLED) }; } }); } -unsafe extern "C" fn evt_io_read(_queue: WDFQUEUE, request: WDFREQUEST, length: usize) { - // SAFETY: WDF transfers the valid request to this read callback. - dispatch_request(request, || unsafe { handle_read(request, length) }); +unsafe extern "C" fn evt_io_read(queue: WDFQUEUE, request: WDFREQUEST, length: usize) { + dispatch_request(request, || { + // SAFETY: WDF keeps the queue's parent device alive for this callback. + let Some(state) = (unsafe { device_state_from_queue(queue) }) else { + return RequestDisposition::error(STATUS_UNSUCCESSFUL); + }; + // SAFETY: WDF transfers the valid request to this read callback. + unsafe { handle_read(state, request, length) } + }); } -unsafe extern "C" fn evt_io_write(_queue: WDFQUEUE, request: WDFREQUEST, length: usize) { - // SAFETY: WDF transfers the valid request to this write callback. - dispatch_request(request, || unsafe { handle_write(request, length) }); +unsafe extern "C" fn evt_io_write(queue: WDFQUEUE, request: WDFREQUEST, length: usize) { + dispatch_request(request, || { + // SAFETY: WDF keeps the queue's parent device alive for this callback. + let Some(state) = (unsafe { device_state_from_queue(queue) }) else { + return RequestDisposition::error(STATUS_UNSUCCESSFUL); + }; + // SAFETY: WDF transfers the valid request to this write callback. + unsafe { handle_write(state, request, length) } + }); } unsafe extern "C" fn evt_io_default(_queue: WDFQUEUE, request: WDFREQUEST) { @@ -361,21 +444,25 @@ unsafe extern "C" fn evt_io_canceled_on_queue(_queue: WDFQUEUE, request: WDFREQU }); } -unsafe fn handle_read(request: WDFREQUEST, length: usize) -> RequestDisposition { +unsafe fn handle_read( + state: &DeviceState, + request: WDFREQUEST, + length: usize, +) -> RequestDisposition { // SAFETY: The invoking WDF callback owns this request. let Some((role, file)) = (unsafe { request_identity(request) }) else { return RequestDisposition::error(STATUS_OBJECT_NAME_INVALID); }; - if !channel().owns(role, file) { + if !state.channel().owns(role, file) { return RequestDisposition::error(STATUS_INVALID_DEVICE_REQUEST); } if length == 0 { return RequestDisposition::success(0); } - let available = channel().plane.available_to_read(role); + let available = state.channel().plane.available_to_read(role); match available { Ok(0) => { - let queue = queue_for(role); + let queue = state.queue_for(role); if queue.is_null() { return RequestDisposition::error(STATUS_UNSUCCESSFUL); } @@ -390,17 +477,21 @@ unsafe fn handle_read(request: WDFREQUEST, length: usize) -> RequestDisposition } } // SAFETY: The invoking WDF callback still owns this request. - Ok(_) => unsafe { read_available(request, role, length) }, + Ok(_) => unsafe { read_available(state, request, role, length) }, Err(error) => RequestDisposition::error(status_for_error(error)), } } -unsafe fn handle_write(request: WDFREQUEST, length: usize) -> RequestDisposition { +unsafe fn handle_write( + state: &DeviceState, + request: WDFREQUEST, + length: usize, +) -> RequestDisposition { // SAFETY: The invoking WDF callback owns this request. let Some((role, file)) = (unsafe { request_identity(request) }) else { return RequestDisposition::error(STATUS_OBJECT_NAME_INVALID); }; - if !channel().owns(role, file) { + if !state.channel().owns(role, file) { return RequestDisposition::error(STATUS_INVALID_DEVICE_REQUEST); } if length == 0 { @@ -424,11 +515,11 @@ unsafe fn handle_write(request: WDFREQUEST, length: usize) -> RequestDisposition let usable = length.min(buffer_length); // SAFETY: WDF returned at least `buffer_length` readable bytes. let input = unsafe { slice::from_raw_parts(buffer.cast_const().cast::(), usable) }; - let write_result = channel().plane.write(role, input); + let write_result = state.channel().plane.write(role, input); match write_result { Ok(written) => { // SAFETY: The peer's pending queue belongs to this device. - unsafe { service_pending_reads(role.peer()) }; + unsafe { service_pending_reads(state, role.peer()) }; RequestDisposition::success(written) } Err(error) => RequestDisposition::error(status_for_error(error)), @@ -436,6 +527,7 @@ unsafe fn handle_write(request: WDFREQUEST, length: usize) -> RequestDisposition } unsafe fn read_available( + state: &DeviceState, request: WDFREQUEST, role: ChannelRole, requested: usize, @@ -458,17 +550,18 @@ unsafe fn read_available( let usable = requested.min(buffer_length); // SAFETY: WDF returned at least `buffer_length` writable bytes. let output = unsafe { slice::from_raw_parts_mut(buffer.cast::(), usable) }; - let read_result = channel().plane.read(role, output); + let read_result = state.channel().plane.read(role, output); match read_result { Ok(read) => RequestDisposition::success(read), Err(error) => RequestDisposition::error(status_for_error(error)), } } -unsafe fn service_pending_reads(role: ChannelRole) { - let queue = queue_for(role); +unsafe fn service_pending_reads(state: &DeviceState, role: ChannelRole) { + let queue = state.queue_for(role); loop { - if channel() + if state + .channel() .plane .available_to_read(role) .map_or(true, |available| available == 0) @@ -488,7 +581,7 @@ unsafe fn service_pending_reads(role: ChannelRole) { break; } // SAFETY: Retrieval transfers the queued request to this driver. - let disposition = unsafe { read_available(request, role, usize::MAX) }; + let disposition = unsafe { read_available(state, request, role, usize::MAX) }; // SAFETY: The retrieved request is no longer owned by the manual queue. unsafe { finish_request(request, disposition) }; } @@ -594,17 +687,77 @@ unsafe fn complete_request(request: WDFREQUEST, status: NTSTATUS, information: u } } -fn channel() -> MutexGuard<'static, ChannelState> { - CHANNEL - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) +unsafe fn initialize_device_context_type() { + let type_info = &raw mut DEVICE_CONTEXT_TYPE_INFO; + let size = struct_size::(); + // SAFETY: DriverEntry is the only writer and initializes this field before publishing it. + unsafe { (*type_info).Size = size }; + // SAFETY: DriverEntry is the only writer and publishes the immutable self-pointer before WDF + // receives the type information. + unsafe { (*type_info).UniqueType = type_info.cast_const() }; } -fn queue_for(role: ChannelRole) -> WDFQUEUE { - match role { - ChannelRole::Application => APPLICATION_READS.load(Ordering::Acquire), - ChannelRole::Daemon => DAEMON_READS.load(Ordering::Acquire), +fn device_context_type_info() -> *const WDF_OBJECT_CONTEXT_TYPE_INFO { + &raw const DEVICE_CONTEXT_TYPE_INFO +} + +unsafe fn device_context(device: WDFDEVICE) -> Option<*mut DeviceContext> { + // SAFETY: Caller supplies a live WDF device. The type-info pointer is initialized before WDF + // creates any device objects and remains process-static. + let context = unsafe { + call_unsafe_wdf_function_binding!( + WdfObjectGetTypedContextWorker, + device.cast::(), + device_context_type_info(), + ) + }; + (!context.is_null()).then(|| context.cast::()) +} + +unsafe fn device_state<'device>(device: WDFDEVICE) -> Option<&'device DeviceState> { + // SAFETY: Caller guarantees that `device` remains alive for the returned borrow. + let context = unsafe { device_context(device) }?; + // SAFETY: The context is initialized before interfaces and queues can invoke callbacks. + let state = unsafe { (*context).state }; + // SAFETY: Device context destroy owns and frees this pointer after all child callbacks finish. + unsafe { state.as_ref() } +} + +unsafe fn device_state_from_file<'device>(file: WDFFILEOBJECT) -> Option<&'device DeviceState> { + // SAFETY: Caller supplies a live WDF file object. + let device = unsafe { call_unsafe_wdf_function_binding!(WdfFileObjectGetDevice, file) }; + if device.is_null() { + return None; + } + // SAFETY: The file object keeps its parent device alive for the callback's duration. + unsafe { device_state(device) } +} + +unsafe fn device_state_from_queue<'device>(queue: WDFQUEUE) -> Option<&'device DeviceState> { + // SAFETY: Caller supplies a live WDF queue. + let device = unsafe { call_unsafe_wdf_function_binding!(WdfIoQueueGetDevice, queue) }; + if device.is_null() { + return None; } + // SAFETY: The queue keeps its parent device alive for the callback's duration. + unsafe { device_state(device) } +} + +unsafe extern "C" fn evt_device_context_destroy(object: WDFOBJECT) { + ffi_void(|| { + // SAFETY: WDF calls this for the device object whose context is being destroyed. + let Some(context) = (unsafe { device_context(object.cast()) }) else { + return; + }; + // SAFETY: WDF supplies this context pointer for the object being destroyed. + let state_slot = unsafe { &raw mut (*context).state }; + // SAFETY: This destroy callback is the sole consumer of the state pointer. + let state = unsafe { ptr::replace(state_slot, ptr::null_mut()) }; + if !state.is_null() { + // SAFETY: `state` originated from exactly one `Box::into_raw` during device creation. + unsafe { drop(Box::from_raw(state)) }; + } + }); } const fn nt_success(status: NTSTATUS) -> bool { @@ -672,4 +825,38 @@ mod tests { assert_eq!(role_from_name(&daemon), Some(ChannelRole::Daemon)); assert_eq!(role_from_name(&unknown), None); } + + #[test] + fn device_states_keep_transport_and_queues_independent() { + let first = DeviceState::new(); + let second = DeviceState::new(); + + { + let mut channel = first.channel(); + assert_eq!(channel.plane.open(ChannelRole::Application), Ok(1)); + assert_eq!(channel.plane.open(ChannelRole::Daemon), Ok(1)); + assert_eq!(channel.plane.write(ChannelRole::Application, b"one"), Ok(3)); + drop(channel); + } + { + let mut channel = second.channel(); + assert_eq!(channel.plane.open(ChannelRole::Application), Ok(1)); + assert_eq!(channel.plane.open(ChannelRole::Daemon), Ok(1)); + drop(channel); + } + + assert_eq!( + first.channel().plane.available_to_read(ChannelRole::Daemon), + Ok(3) + ); + assert_eq!( + second + .channel() + .plane + .available_to_read(ChannelRole::Daemon), + Ok(0) + ); + assert!(first.queue_for(ChannelRole::Application).is_null()); + assert!(second.queue_for(ChannelRole::Daemon).is_null()); + } } From 180a8b56d4b8f1fab33c243c226357a3f18c88e8 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 19:24:06 -0700 Subject: [PATCH 05/39] Expose UMDF device as a serial port --- drivers/cathub-virtual-serial-umdf/README.md | 25 +- .../cathub_virtual_serial_umdf.inx | 28 +- .../src/data_plane.rs | 39 ++ .../cathub-virtual-serial-umdf/src/interop.rs | 514 +++++++++++++- drivers/cathub-virtual-serial-umdf/src/lib.rs | 2 + .../cathub-virtual-serial-umdf/src/serial.rs | 633 ++++++++++++++++++ 6 files changed, 1204 insertions(+), 37 deletions(-) create mode 100644 drivers/cathub-virtual-serial-umdf/src/serial.rs diff --git a/drivers/cathub-virtual-serial-umdf/README.md b/drivers/cathub-virtual-serial-umdf/README.md index a0e3df2..2eed6f6 100644 --- a/drivers/cathub-virtual-serial-umdf/README.md +++ b/drivers/cathub-virtual-serial-umdf/README.md @@ -1,13 +1,13 @@ -# CatHub pure Rust UMDF proof of concept +# CatHub pure Rust UMDF virtual serial driver -This isolated Cargo package proves the first build and ABI boundary for issue #6. +This isolated Cargo package implements the Windows driver side of issue #6. It is not part of the normal CatHub workspace because `windows-drivers-rs` supports one WDK configuration per Cargo build graph. -The current driver creates a private proof-of-concept-class WDF device and registers two -reference-named instances of private interface -`{0084BDDE-9F40-4A6A-AF84-0F4E46B70901}`: `application` and `daemon`. -It does not register `GUID_DEVINTERFACE_COMPORT` or claim a COM number. +The current driver installs as a Ports-class WDF device, registers `GUID_DEVINTERFACE_COMPORT`, +uses the COM number assigned by the Windows Ports class installer, and creates the corresponding +global `COMx` symbolic link. It also registers the reference-named `daemon` instance of the private +CatHub interface `{0084BDDE-9F40-4A6A-AF84-0F4E46B70901}`. Do not install it on the working station. ## Pinned inputs @@ -71,7 +71,7 @@ All Windows and WDF calls live in `src/interop.rs`. Every exported or registered callback catches Rust panics before they can unwind into WDF. The driver contains no kernel-mode CatHub code and no C or C++ shim. -The proof-of-concept data plane currently provides: +The data plane currently provides: - one exclusive handle for each reference name; - independent 64 KiB bounded queues in both directions; @@ -80,10 +80,13 @@ The proof-of-concept data plane currently provides: - fail-closed cleanup that clears buffered bytes and completes both sides' pending reads when either handle disconnects, with new reads and writes rejected until both peers reconnect. +The public COM handle implements the Windows serial controls used by the Phase 1 conformance +harness, including baud/line settings, timeouts, flow control, special characters, modem lines, +queue status, purge, immediate characters, and `WaitCommEvent`. Unsupported IOCTLs fail explicitly. + Each WDF device owns its transport state and pending-read queues through typed object context, and the context's destroy callback releases the Rust-owned state during device teardown. -This remains a raw-byte proof of concept. It does not yet decode the shared private framing -contract, implement serial timeouts or controls, restrict the daemon interface to a service SID, or -expose a real application COM port. The INF stays in the private proof-of-concept class until those -behaviors are implemented and verified on an isolated driver-development target. +The private daemon handle remains a raw-byte channel. It does not yet decode the shared framing +contract or restrict the interface to a service SID. The application COM path and driver package +must still be installed and verified on the isolated driver-development target. diff --git a/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx b/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx index f976b88..c2ebf88 100644 --- a/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx +++ b/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx @@ -1,10 +1,9 @@ -; CatHub pure Rust UMDF 2 proof-of-concept package. -; This private proof-of-concept class does not expose a COM port yet. +; CatHub pure Rust UMDF 2 virtual serial port package. [Version] Signature = "$WINDOWS NT$" -Class = CatHubPoC -ClassGuid = {513D0917-B448-4C5D-ADBF-14E156572683} +Class = Ports +ClassGuid = {4D36E978-E325-11CE-BFC1-08002BE10318} Provider = %ProviderString% CatalogFile = cathub_virtual_serial_umdf.cat PnpLockDown = 1 @@ -18,18 +17,11 @@ DefaultDestDir = 13 [SourceDisksFiles] cathub_virtual_serial_umdf.dll = 1,, -[ClassInstall32] -AddReg=CatHubPoCClassReg - -[CatHubPoCClassReg] -HKR,,,0,%ClassName% -HKR,,Icon,,-5 - [Manufacturer] %ManufacturerName%=Standard,NT$ARCH$.10.0...22000 [Standard.NT$ARCH$.10.0...22000] -%DeviceDesc%=CatHubUMDFDevice_Install, root\CATHUB_UMDF_POC +%DeviceDesc%=CatHubUMDFDevice_Install, root\CATHUB_VIRTUAL_SERIAL [CatHubUMDFDevice_Install.NT] CopyFiles=DriverCopy @@ -40,6 +32,7 @@ Needs=WUDFRD.NT cathub_virtual_serial_umdf.dll [CatHubUMDFDevice_Install.NT.HW] +AddReg=SetDeviceType_AddReg Include=WUDFRD.inf Needs=WUDFRD.NT.HW @@ -50,14 +43,19 @@ Needs=WUDFRD.NT.Services [CatHubUMDFDevice_Install.NT.Wdf] UmdfService=cathub_virtual_serial_umdf, CatHubUMDFDevice_WdfInstall UmdfServiceOrder=cathub_virtual_serial_umdf +UmdfKernelModeClientPolicy=AllowKernelModeClients +UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects +UmdfFsContextUsePolicy=CannotUseFsContexts [CatHubUMDFDevice_WdfInstall] UmdfLibraryVersion=$UMDFVERSION$ ServiceBinary=%13%\cathub_virtual_serial_umdf.dll +[SetDeviceType_AddReg] +HKR,,DeviceType,0x10001,0x0000001b + [Strings] ProviderString="CatHub contributors" ManufacturerName="CatHub contributors" -DiskId1="CatHub UMDF proof-of-concept installation media" -DeviceDesc="CatHub UMDF proof-of-concept device" -ClassName="CatHub proof-of-concept devices" +DiskId1="CatHub virtual serial installation media" +DeviceDesc="CatHub Virtual Serial Port" diff --git a/drivers/cathub-virtual-serial-umdf/src/data_plane.rs b/drivers/cathub-virtual-serial-umdf/src/data_plane.rs index 8afdf8d..18ddcc7 100644 --- a/drivers/cathub-virtual-serial-umdf/src/data_plane.rs +++ b/drivers/cathub-virtual-serial-umdf/src/data_plane.rs @@ -130,6 +130,28 @@ impl EndpointDataPlane { Ok(self.incoming_mut(role).pop_into(output)) } + /// Discard bytes waiting to be read by `role`. + pub fn clear_incoming(&mut self, role: ChannelRole) { + self.incoming_mut(role).clear(); + } + + /// Discard bytes written by `role` but not yet read by its peer. + pub fn clear_outgoing(&mut self, role: ChannelRole) { + self.outgoing_mut(role).clear(); + } + + /// Return queued bytes waiting for `role`, even while its peer is disconnected. + #[must_use] + pub fn incoming_len(&self, role: ChannelRole) -> usize { + self.incoming(role).len() + } + + /// Return bytes written by `role` and waiting for its peer. + #[must_use] + pub fn outgoing_len(&self, role: ChannelRole) -> usize { + self.incoming(role.peer()).len() + } + const fn require_open(&self, role: ChannelRole) -> Result<(), DataPlaneError> { let open = match role { ChannelRole::Application => self.application_open, @@ -317,4 +339,21 @@ mod tests { assert_eq!(plane.open(ChannelRole::Application), Ok(2)); assert_eq!(plane.available_to_read(ChannelRole::Application), Ok(0)); } + + #[test] + fn purge_directions_are_independent() { + let mut plane = connected(8); + plane + .write(ChannelRole::Application, b"out") + .expect("application write"); + plane + .write(ChannelRole::Daemon, b"in") + .expect("daemon write"); + + plane.clear_incoming(ChannelRole::Application); + assert_eq!(plane.incoming_len(ChannelRole::Application), 0); + assert_eq!(plane.outgoing_len(ChannelRole::Application), 3); + plane.clear_outgoing(ChannelRole::Application); + assert_eq!(plane.outgoing_len(ChannelRole::Application), 0); + } } diff --git a/drivers/cathub-virtual-serial-umdf/src/interop.rs b/drivers/cathub-virtual-serial-umdf/src/interop.rs index 6500786..27f7866 100644 --- a/drivers/cathub-virtual-serial-umdf/src/interop.rs +++ b/drivers/cathub-virtual-serial-umdf/src/interop.rs @@ -14,18 +14,24 @@ use std::{ use wdk::println; use wdk_sys::{ _WDF_EXECUTION_LEVEL, _WDF_FILEOBJECT_CLASS, _WDF_IO_QUEUE_DISPATCH_TYPE, - _WDF_SYNCHRONIZATION_SCOPE, _WDF_TRI_STATE, BOOLEAN, GUID, NTSTATUS, PCUNICODE_STRING, - PDRIVER_OBJECT, PVOID, ULONG, ULONG_PTR, UNICODE_STRING, WDF_DRIVER_CONFIG, - WDF_FILEOBJECT_CONFIG, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, - WDF_OBJECT_ATTRIBUTES, WDF_OBJECT_CONTEXT_TYPE_INFO, WDFDEVICE, WDFDEVICE_INIT, WDFDRIVER, - WDFFILEOBJECT, WDFOBJECT, WDFQUEUE, WDFQUEUE__, WDFREQUEST, call_unsafe_wdf_function_binding, + _WDF_SYNCHRONIZATION_SCOPE, _WDF_TRI_STATE, BOOLEAN, GUID, KEY_QUERY_VALUE, NTSTATUS, + PCUNICODE_STRING, PDRIVER_OBJECT, PLUGPLAY_REGKEY_DEVICE, PVOID, ULONG, ULONG_PTR, + UNICODE_STRING, WDF_DRIVER_CONFIG, WDF_FILEOBJECT_CONFIG, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, + WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES, WDF_OBJECT_CONTEXT_TYPE_INFO, WDFDEVICE, + WDFDEVICE_INIT, WDFDRIVER, WDFFILEOBJECT, WDFKEY, WDFOBJECT, WDFQUEUE, WDFQUEUE__, WDFREQUEST, + call_unsafe_wdf_function_binding, }; use crate::data_plane::{ChannelRole, DEFAULT_BUFFER_CAPACITY, DataPlaneError, EndpointDataPlane}; +use crate::serial::{ + SerialBaudRate, SerialChars, SerialCommProperties, SerialHandflow, SerialLineControl, + SerialQueueSize, SerialState, SerialStateError, SerialStatus, SerialTimeouts, ioctl, purge, +}; const STATUS_SUCCESS: NTSTATUS = 0; const STATUS_UNSUCCESSFUL: NTSTATUS = -1_073_741_823; const STATUS_INVALID_DEVICE_REQUEST: NTSTATUS = -1_073_741_808; +const STATUS_INVALID_PARAMETER: NTSTATUS = -1_073_741_811; const STATUS_OBJECT_NAME_INVALID: NTSTATUS = -1_073_741_773; const STATUS_SHARING_VIOLATION: NTSTATUS = -1_073_741_757; const STATUS_DEVICE_NOT_CONNECTED: NTSTATUS = -1_073_741_667; @@ -33,8 +39,11 @@ const STATUS_CANCELLED: NTSTATUS = -1_073_741_536; const STATUS_BUFFER_OVERFLOW: NTSTATUS = -2_147_483_643; const TRUE: BOOLEAN = 1; -const APPLICATION_REFERENCE: [u16; 12] = [97, 112, 112, 108, 105, 99, 97, 116, 105, 111, 110, 0]; const DAEMON_REFERENCE: [u16; 7] = [100, 97, 101, 109, 111, 110, 0]; +const PORT_NAME_VALUE: [u16; 9] = [80, 111, 114, 116, 78, 97, 109, 101, 0]; +const DOS_DEVICE_PREFIX: &[u16] = &[ + 92, 68, 111, 115, 68, 101, 118, 105, 99, 101, 115, 92, 71, 108, 111, 98, 97, 108, 92, +]; /// Private proof-of-concept interface, `{0084BDDE-9F40-4A6A-AF84-0F4E46B70901}`. static CATHUB_POC_INTERFACE_GUID: GUID = GUID { @@ -44,6 +53,14 @@ static CATHUB_POC_INTERFACE_GUID: GUID = GUID { Data4: [0xAF, 0x84, 0x0F, 0x4E, 0x46, 0xB7, 0x09, 0x01], }; +/// Standard Windows COM-port interface, `{86E0D1E0-8089-11D0-9CE4-08003E301F73}`. +static GUID_DEVINTERFACE_COMPORT: GUID = GUID { + Data1: 0x86E0_D1E0, + Data2: 0x8089, + Data3: 0x11D0, + Data4: [0x9C, 0xE4, 0x08, 0x00, 0x3E, 0x30, 0x1F, 0x73], +}; + #[repr(C)] struct DeviceContext { state: *mut DeviceState, @@ -51,16 +68,22 @@ struct DeviceContext { struct DeviceState { channel: Mutex, + serial: Mutex, application_reads: AtomicPtr, daemon_reads: AtomicPtr, + wait_requests: AtomicPtr, } impl DeviceState { fn new() -> Self { + let mut serial = SerialState::default(); + serial.set_modem_input(serial.modem_input()); Self { channel: Mutex::new(ChannelState::new()), + serial: Mutex::new(serial), application_reads: AtomicPtr::new(ptr::null_mut()), daemon_reads: AtomicPtr::new(ptr::null_mut()), + wait_requests: AtomicPtr::new(ptr::null_mut()), } } @@ -76,6 +99,16 @@ impl DeviceState { ChannelRole::Daemon => self.daemon_reads.load(Ordering::Acquire), } } + + fn serial(&self) -> MutexGuard<'_, SerialState> { + self.serial + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn wait_queue(&self) -> WDFQUEUE { + self.wait_requests.load(Ordering::Acquire) + } } static mut DEVICE_CONTEXT_TYPE_INFO: WDF_OBJECT_CONTEXT_TYPE_INFO = WDF_OBJECT_CONTEXT_TYPE_INFO { @@ -288,6 +321,14 @@ unsafe fn configure_queues(device: WDFDEVICE, state: &DeviceState) -> NTSTATUS { } state.daemon_reads.store(daemon_reads, Ordering::Release); + let mut wait_requests = ptr::null_mut(); + // SAFETY: The device and output storage are valid for synchronous queue creation. + let status = unsafe { create_manual_read_queue(device, &raw mut wait_requests) }; + if !nt_success(status) { + return status; + } + state.wait_requests.store(wait_requests, Ordering::Release); + let mut default_queue: WDFQUEUE = ptr::null_mut(); let mut config = WDF_IO_QUEUE_CONFIG { Size: struct_size::(), @@ -298,6 +339,7 @@ unsafe fn configure_queues(device: WDFDEVICE, state: &DeviceState) -> NTSTATUS { EvtIoDefault: Some(evt_io_default), EvtIoRead: Some(evt_io_read), EvtIoWrite: Some(evt_io_write), + EvtIoDeviceControl: Some(evt_io_device_control), ..WDF_IO_QUEUE_CONFIG::default() }; // SAFETY: WDF copies the config; null attributes are allowed and output storage is valid. @@ -333,19 +375,23 @@ unsafe fn create_manual_read_queue(device: WDFDEVICE, queue: *mut WDFQUEUE) -> N } unsafe fn register_interfaces(device: WDFDEVICE) -> NTSTATUS { - let application = unicode_string(&APPLICATION_REFERENCE); - // SAFETY: All pointers remain valid through this synchronous call. + // SAFETY: The device is live and the interface GUID has static storage. let status = unsafe { call_unsafe_wdf_function_binding!( WdfDeviceCreateDeviceInterface, device, - &raw const CATHUB_POC_INTERFACE_GUID, - &raw const application, + &raw const GUID_DEVINTERFACE_COMPORT, + ptr::null(), ) }; if !nt_success(status) { return status; } + // SAFETY: The device instance key and PortName value are owned by Windows Ports setup. + let status = unsafe { create_com_symbolic_link(device) }; + if !nt_success(status) { + return status; + } let daemon = unicode_string(&DAEMON_REFERENCE); // SAFETY: All pointers remain valid through this synchronous call. unsafe { @@ -358,6 +404,60 @@ unsafe fn register_interfaces(device: WDFDEVICE) -> NTSTATUS { } } +unsafe fn create_com_symbolic_link(device: WDFDEVICE) -> NTSTATUS { + let mut key: WDFKEY = ptr::null_mut(); + // SAFETY: The device is live, null attributes are allowed, and output storage is valid. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceOpenRegistryKey, + device, + PLUGPLAY_REGKEY_DEVICE, + KEY_QUERY_VALUE, + WDF_NO_OBJECT_ATTRIBUTES, + &raw mut key, + ) + }; + if !nt_success(status) { + return status; + } + + let value_name = unicode_string(&PORT_NAME_VALUE); + let mut port_buffer = [0_u16; 64]; + let mut port_name = unicode_string_buffer(&mut port_buffer); + // SAFETY: `key` is open, and both counted strings remain valid through the call. + let query_status = unsafe { + call_unsafe_wdf_function_binding!( + WdfRegistryQueryUnicodeString, + key, + &raw const value_name, + ptr::null_mut(), + &raw mut port_name, + ) + }; + // SAFETY: This driver owns the WDF registry-key handle returned above. + unsafe { call_unsafe_wdf_function_binding!(WdfRegistryClose, key) }; + if !nt_success(query_status) { + return query_status; + } + let port_units = usize::from(port_name.Length) / size_of::(); + if port_units == 0 || port_units >= port_buffer.len() { + return STATUS_OBJECT_NAME_INVALID; + } + let mut link = Vec::with_capacity(DOS_DEVICE_PREFIX.len() + port_units + 1); + link.extend_from_slice(DOS_DEVICE_PREFIX); + link.extend_from_slice(port_buffer.get(..port_units).unwrap_or_default()); + link.push(0); + let symbolic_link = unicode_string(&link); + // SAFETY: The device is live and the counted link string remains valid synchronously. + unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceCreateSymbolicLink, + device, + &raw const symbolic_link, + ) + } +} + unsafe extern "C" fn evt_device_file_create( device: WDFDEVICE, request: WDFREQUEST, @@ -404,6 +504,8 @@ unsafe extern "C" fn evt_file_cleanup(file: WDFFILEOBJECT) { }; // SAFETY: This handle refers to a manual queue owned by this device. unsafe { drain_pending_reads(state.queue_for(ChannelRole::Daemon), STATUS_CANCELLED) }; + // SAFETY: This handle refers to a manual queue owned by this device. + unsafe { drain_pending_reads(state.wait_queue(), STATUS_CANCELLED) }; } }); } @@ -430,6 +532,23 @@ unsafe extern "C" fn evt_io_write(queue: WDFQUEUE, request: WDFREQUEST, length: }); } +unsafe extern "C" fn evt_io_device_control( + queue: WDFQUEUE, + request: WDFREQUEST, + _output_length: usize, + _input_length: usize, + control_code: ULONG, +) { + dispatch_request(request, || { + // SAFETY: WDF keeps the queue's parent device alive for this callback. + let Some(state) = (unsafe { device_state_from_queue(queue) }) else { + return RequestDisposition::error(STATUS_UNSUCCESSFUL); + }; + // SAFETY: WDF transfers the valid request to this device-control callback. + unsafe { handle_device_control(state, request, control_code) } + }); +} + unsafe extern "C" fn evt_io_default(_queue: WDFQUEUE, request: WDFREQUEST) { ffi_void(|| { // SAFETY: WDF transfers ownership of this unsupported request to the callback. @@ -518,6 +637,14 @@ unsafe fn handle_write( let write_result = state.channel().plane.write(role, input); match write_result { Ok(written) => { + if role == ChannelRole::Application { + state.serial().signal_transmit_empty(); + } else { + let queued = state.channel().plane.incoming_len(ChannelRole::Application); + state.serial().signal_receive(input, queued); + } + // SAFETY: The wait-request queue belongs to this device. + unsafe { service_wait_request(state) }; // SAFETY: The peer's pending queue belongs to this device. unsafe { service_pending_reads(state, role.peer()) }; RequestDisposition::success(written) @@ -526,6 +653,357 @@ unsafe fn handle_write( } } +// An exhaustive IOCTL table is easier to audit when kept as one dispatch function. +#[allow(clippy::too_many_lines)] +unsafe fn handle_device_control( + state: &DeviceState, + request: WDFREQUEST, + control_code: u32, +) -> RequestDisposition { + // SAFETY: The invoking WDF callback owns this request. + let Some((role, file)) = (unsafe { request_identity(request) }) else { + return RequestDisposition::error(STATUS_OBJECT_NAME_INVALID); + }; + if role != ChannelRole::Application || !state.channel().owns(role, file) { + return RequestDisposition::error(STATUS_INVALID_DEVICE_REQUEST); + } + + match control_code { + ioctl::SET_BAUD_RATE => { + // SAFETY: The request is a buffered serial IOCTL with this documented layout. + let value = unsafe { request_input::(request) }; + serial_set(value, SerialState::set_baud_rate, state) + } + ioctl::GET_BAUD_RATE => { + // SAFETY: The request is a buffered serial IOCTL with this documented layout. + unsafe { request_output(request, &state.serial().baud_rate()) } + } + ioctl::SET_QUEUE_SIZE => { + // SAFETY: The request is a buffered serial IOCTL with this documented layout. + let value = unsafe { request_input::(request) }; + serial_set(value, |serial, value| serial.set_queue_size(value), state) + } + ioctl::SET_LINE_CONTROL => { + // SAFETY: The request is a buffered serial IOCTL with this documented layout. + let value = unsafe { request_input::(request) }; + serial_set(value, SerialState::set_line_control, state) + } + ioctl::GET_LINE_CONTROL => { + // SAFETY: The request is a buffered serial IOCTL with this documented layout. + unsafe { request_output(request, &state.serial().line_control()) } + } + ioctl::SET_TIMEOUTS => { + // SAFETY: The request is a buffered serial IOCTL with this documented layout. + let value = unsafe { request_input::(request) }; + serial_set(value, SerialState::set_timeouts, state) + } + ioctl::GET_TIMEOUTS => { + // SAFETY: The request is a buffered serial IOCTL with this documented layout. + unsafe { request_output(request, &state.serial().timeouts()) } + } + ioctl::SET_CHARS => { + // SAFETY: The request is a buffered serial IOCTL with this documented layout. + let value = unsafe { request_input::(request) }; + serial_set(value, SerialState::set_chars, state) + } + ioctl::GET_CHARS => { + // SAFETY: The request is a buffered serial IOCTL with this documented layout. + unsafe { request_output(request, &state.serial().chars()) } + } + ioctl::SET_HANDFLOW => { + // SAFETY: The request is a buffered serial IOCTL with this documented layout. + let value = unsafe { request_input::(request) }; + serial_set(value, SerialState::set_handflow, state) + } + ioctl::GET_HANDFLOW => { + // SAFETY: The request is a buffered serial IOCTL with this documented layout. + unsafe { request_output(request, &state.serial().handflow()) } + } + ioctl::SET_BREAK_ON => { + state.serial().set_break(true); + RequestDisposition::success(0) + } + ioctl::SET_BREAK_OFF => { + state.serial().set_break(false); + RequestDisposition::success(0) + } + ioctl::SET_DTR => { + state.serial().set_dtr(true); + RequestDisposition::success(0) + } + ioctl::CLR_DTR => { + state.serial().set_dtr(false); + RequestDisposition::success(0) + } + ioctl::SET_RTS => { + state.serial().set_rts(true); + RequestDisposition::success(0) + } + ioctl::CLR_RTS => { + state.serial().set_rts(false); + RequestDisposition::success(0) + } + ioctl::GET_DTR_RTS | ioctl::GET_MODEM_CONTROL => { + // SAFETY: The output buffer receives one documented 32-bit serial line bitmap. + unsafe { request_output(request, &state.serial().modem_output()) } + } + ioctl::SET_MODEM_CONTROL => { + // SAFETY: The input buffer contains one documented 32-bit serial line bitmap. + match unsafe { request_input::(request) } { + Ok(value) => { + state.serial().set_modem_output(value); + RequestDisposition::success(0) + } + Err(status) => RequestDisposition::error(status), + } + } + ioctl::GET_MODEM_STATUS => { + // SAFETY: The output buffer receives one documented 32-bit modem status bitmap. + unsafe { request_output(request, &state.serial().modem_input()) } + } + ioctl::SET_FIFO_CONTROL => { + // SAFETY: The input buffer contains one documented 32-bit FIFO value. + match unsafe { request_input::(request) } { + Ok(value) => { + state.serial().set_fifo_control(value); + RequestDisposition::success(0) + } + Err(status) => RequestDisposition::error(status), + } + } + ioctl::GET_WAIT_MASK => { + // SAFETY: The output buffer receives one documented 32-bit wait mask. + unsafe { request_output(request, &state.serial().wait_mask()) } + } + ioctl::SET_WAIT_MASK => { + // SAFETY: `set_wait_mask` owns any request retrieved from the manual wait queue. + unsafe { set_wait_mask(state, request) } + } + ioctl::WAIT_ON_MASK => { + // SAFETY: The request remains owned by this callback or is forwarded exactly once. + unsafe { wait_on_mask(state, request) } + } + ioctl::PURGE => { + // SAFETY: `purge_queues` reads the documented mask and owns affected queued requests. + unsafe { purge_queues(state, request) } + } + ioctl::GET_COMM_STATUS => { + let (input, output) = { + let channel = state.channel(); + ( + channel.plane.incoming_len(ChannelRole::Application), + channel.plane.outgoing_len(ChannelRole::Application), + ) + }; + let status: SerialStatus = state.serial().status(input, output); + // SAFETY: The output buffer receives the documented serial status layout. + unsafe { request_output(request, &status) } + } + ioctl::GET_PROPERTIES => { + let properties: SerialCommProperties = state.serial().properties(); + // SAFETY: The output buffer receives the documented serial properties layout. + unsafe { request_output(request, &properties) } + } + ioctl::IMMEDIATE_CHAR => { + // SAFETY: The input buffer contains the immediate byte. + unsafe { immediate_char(state, request) } + } + ioctl::RESET_DEVICE | ioctl::SET_XON | ioctl::SET_XOFF => RequestDisposition::success(0), + _ => RequestDisposition::error(STATUS_INVALID_DEVICE_REQUEST), + } +} + +fn serial_set( + value: Result, + setter: impl FnOnce(&mut SerialState, T) -> Result<(), SerialStateError>, + state: &DeviceState, +) -> RequestDisposition { + let value = match value { + Ok(value) => value, + Err(status) => return RequestDisposition::error(status), + }; + let set_result = setter(&mut state.serial(), value); + match set_result { + Ok(()) => RequestDisposition::success(0), + Err(error) => RequestDisposition::error(status_for_serial_error(error)), + } +} + +unsafe fn request_input(request: WDFREQUEST) -> Result { + let mut buffer: PVOID = ptr::null_mut(); + let mut length = 0; + // SAFETY: WDF owns the request and returns a buffer valid until request completion. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfRequestRetrieveInputBuffer, + request, + size_of::(), + &raw mut buffer, + &raw mut length, + ) + }; + if !nt_success(status) { + return Err(status); + } + if buffer.is_null() || length < size_of::() { + return Err(STATUS_INVALID_PARAMETER); + } + // SAFETY: WDF returned at least `size_of::()` readable bytes; unaligned handles any layout. + Ok(unsafe { ptr::read_unaligned(buffer.cast::()) }) +} + +unsafe fn request_output(request: WDFREQUEST, value: &T) -> RequestDisposition { + let mut buffer: PVOID = ptr::null_mut(); + let mut length = 0; + // SAFETY: WDF owns the request and returns a buffer valid until request completion. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfRequestRetrieveOutputBuffer, + request, + size_of::(), + &raw mut buffer, + &raw mut length, + ) + }; + if !nt_success(status) { + return RequestDisposition::error(status); + } + if buffer.is_null() || length < size_of::() { + return RequestDisposition::error(STATUS_INVALID_PARAMETER); + } + // SAFETY: WDF returned at least `size_of::()` writable bytes; unaligned handles any layout. + unsafe { ptr::write_unaligned(buffer.cast::(), *value) }; + RequestDisposition::success(size_of::()) +} + +unsafe fn set_wait_mask(state: &DeviceState, request: WDFREQUEST) -> RequestDisposition { + // SAFETY: The input buffer contains one documented 32-bit wait mask. + let mask = match unsafe { request_input::(request) } { + Ok(mask) => mask, + Err(status) => return RequestDisposition::error(status), + }; + let set_result = state.serial().set_wait_mask(mask); + if let Err(error) = set_result { + return RequestDisposition::error(status_for_serial_error(error)); + } + let mut pending: WDFREQUEST = ptr::null_mut(); + // SAFETY: The manual queue belongs to this device and output storage is valid. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfIoQueueRetrieveNextRequest, + state.wait_queue(), + &raw mut pending, + ) + }; + if nt_success(status) { + // SAFETY: Retrieval transferred ownership of the pending wait request. + let disposition = unsafe { request_output(pending, &0_u32) }; + // SAFETY: The retrieved request is no longer owned by the manual queue. + unsafe { finish_request(pending, disposition) }; + } + RequestDisposition::success(0) +} + +unsafe fn wait_on_mask(state: &DeviceState, request: WDFREQUEST) -> RequestDisposition { + if state.serial().wait_mask() == 0 { + return RequestDisposition::error(STATUS_INVALID_PARAMETER); + } + let ready_events = state.serial().take_wait_events(); + if let Some(events) = ready_events { + // SAFETY: The output buffer receives one documented 32-bit event mask. + return unsafe { request_output(request, &events) }; + } + let mut previous: WDFREQUEST = ptr::null_mut(); + // SAFETY: The manual queue belongs to this device and output storage is valid. + let retrieved = unsafe { + call_unsafe_wdf_function_binding!( + WdfIoQueueRetrieveNextRequest, + state.wait_queue(), + &raw mut previous, + ) + }; + if nt_success(retrieved) { + // SAFETY: Retrieval transferred ownership of the superseded request. + unsafe { complete_request(previous, STATUS_UNSUCCESSFUL, 0) }; + } + // SAFETY: The request is framework-owned and target is a valid manual queue. + let status = unsafe { + call_unsafe_wdf_function_binding!(WdfRequestForwardToIoQueue, request, state.wait_queue(),) + }; + if nt_success(status) { + RequestDisposition::Pending + } else { + RequestDisposition::error(status) + } +} + +unsafe fn service_wait_request(state: &DeviceState) { + let Some(events) = state.serial().take_wait_events() else { + return; + }; + let mut request: WDFREQUEST = ptr::null_mut(); + // SAFETY: The manual queue belongs to this device and output storage is valid. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfIoQueueRetrieveNextRequest, + state.wait_queue(), + &raw mut request, + ) + }; + if nt_success(status) { + // SAFETY: Retrieval transferred the queued request and its buffer remains valid. + let disposition = unsafe { request_output(request, &events) }; + // SAFETY: The retrieved request is no longer owned by the queue. + unsafe { finish_request(request, disposition) }; + } +} + +unsafe fn purge_queues(state: &DeviceState, request: WDFREQUEST) -> RequestDisposition { + // SAFETY: The input buffer contains one documented 32-bit purge mask. + let mask = match unsafe { request_input::(request) } { + Ok(mask) => mask, + Err(status) => return RequestDisposition::error(status), + }; + if let Err(error) = SerialState::validate_purge(mask) { + return RequestDisposition::error(status_for_serial_error(error)); + } + { + let mut channel = state.channel(); + if mask & purge::RX_CLEAR != 0 { + channel.plane.clear_incoming(ChannelRole::Application); + } + if mask & purge::TX_CLEAR != 0 { + channel.plane.clear_outgoing(ChannelRole::Application); + } + } + if mask & purge::RX_ABORT != 0 { + // SAFETY: This manual queue belongs to the application side of this device. + unsafe { drain_pending_reads(state.queue_for(ChannelRole::Application), STATUS_CANCELLED) }; + } + RequestDisposition::success(0) +} + +unsafe fn immediate_char(state: &DeviceState, request: WDFREQUEST) -> RequestDisposition { + // SAFETY: The input buffer contains the documented immediate byte. + let byte = match unsafe { request_input::(request) } { + Ok(byte) => byte, + Err(status) => return RequestDisposition::error(status), + }; + let write_result = state + .channel() + .plane + .write(ChannelRole::Application, &[byte]); + match write_result { + Ok(_) => { + state.serial().signal_transmit_empty(); + // SAFETY: The daemon read queue belongs to this device. + unsafe { service_pending_reads(state, ChannelRole::Daemon) }; + RequestDisposition::success(0) + } + Err(error) => RequestDisposition::error(status_for_error(error)), + } +} + unsafe fn read_available( state: &DeviceState, request: WDFREQUEST, @@ -640,7 +1118,7 @@ fn role_from_name(name: &[u16]) -> Option { let segment = name .rsplit(|unit| *unit == u16::from(b'\\') || *unit == u16::from(b'/')) .next()?; - if utf16_eq_ascii_case(segment, b"application") { + if segment.is_empty() || utf16_eq_ascii_case(segment, b"application") { Some(ChannelRole::Application) } else if utf16_eq_ascii_case(segment, b"daemon") { Some(ChannelRole::Daemon) @@ -773,6 +1251,10 @@ const fn status_for_error(error: DataPlaneError) -> NTSTATUS { } } +const fn status_for_serial_error(_error: SerialStateError) -> NTSTATUS { + STATUS_INVALID_PARAMETER +} + fn struct_size() -> ULONG { u32::try_from(size_of::()).unwrap_or(ULONG::MAX) } @@ -788,6 +1270,15 @@ fn unicode_string(units_with_null: &[u16]) -> UNICODE_STRING { } } +fn unicode_string_buffer(buffer: &mut [u16]) -> UNICODE_STRING { + UNICODE_STRING { + Length: 0, + MaximumLength: u16::try_from(buffer.len().saturating_mul(size_of::())) + .unwrap_or(u16::MAX), + Buffer: buffer.as_mut_ptr(), + } +} + unsafe extern "C" fn evt_driver_unload(_driver: WDFDRIVER) { ffi_void(|| println!("CatHub UMDF proof-of-concept unloaded")); } @@ -821,6 +1312,7 @@ mod tests { let daemon: Vec = "DAEMON".encode_utf16().collect(); let unknown: Vec = "control".encode_utf16().collect(); + assert_eq!(role_from_name(&[]), Some(ChannelRole::Application)); assert_eq!(role_from_name(&application), Some(ChannelRole::Application)); assert_eq!(role_from_name(&daemon), Some(ChannelRole::Daemon)); assert_eq!(role_from_name(&unknown), None); diff --git a/drivers/cathub-virtual-serial-umdf/src/lib.rs b/drivers/cathub-virtual-serial-umdf/src/lib.rs index 98fa6a0..6ad1e5a 100644 --- a/drivers/cathub-virtual-serial-umdf/src/lib.rs +++ b/drivers/cathub-virtual-serial-umdf/src/lib.rs @@ -11,3 +11,5 @@ mod data_plane; #[cfg_attr(test, allow(dead_code))] mod interop; +#[cfg_attr(test, allow(dead_code))] +mod serial; diff --git a/drivers/cathub-virtual-serial-umdf/src/serial.rs b/drivers/cathub-virtual-serial-umdf/src/serial.rs new file mode 100644 index 0000000..bdb86c0 --- /dev/null +++ b/drivers/cathub-virtual-serial-umdf/src/serial.rs @@ -0,0 +1,633 @@ +//! Safe serial-port state and the Windows serial IOCTL wire layout. + +/// Windows device type used by serial-port IOCTLs. +const FILE_DEVICE_SERIAL_PORT: u32 = 0x1b; + +const fn serial_ioctl(function: u32) -> u32 { + (FILE_DEVICE_SERIAL_PORT << 16) | (function << 2) +} + +/// Serial IOCTL function codes accepted by the public COM handle. +pub mod ioctl { + use super::serial_ioctl; + + pub const SET_BAUD_RATE: u32 = serial_ioctl(1); + pub const SET_QUEUE_SIZE: u32 = serial_ioctl(2); + pub const SET_LINE_CONTROL: u32 = serial_ioctl(3); + pub const SET_BREAK_ON: u32 = serial_ioctl(4); + pub const SET_BREAK_OFF: u32 = serial_ioctl(5); + pub const IMMEDIATE_CHAR: u32 = serial_ioctl(6); + pub const SET_TIMEOUTS: u32 = serial_ioctl(7); + pub const GET_TIMEOUTS: u32 = serial_ioctl(8); + pub const SET_DTR: u32 = serial_ioctl(9); + pub const CLR_DTR: u32 = serial_ioctl(10); + pub const RESET_DEVICE: u32 = serial_ioctl(11); + pub const SET_RTS: u32 = serial_ioctl(12); + pub const CLR_RTS: u32 = serial_ioctl(13); + pub const SET_XOFF: u32 = serial_ioctl(14); + pub const SET_XON: u32 = serial_ioctl(15); + pub const GET_WAIT_MASK: u32 = serial_ioctl(16); + pub const SET_WAIT_MASK: u32 = serial_ioctl(17); + pub const WAIT_ON_MASK: u32 = serial_ioctl(18); + pub const PURGE: u32 = serial_ioctl(19); + pub const GET_BAUD_RATE: u32 = serial_ioctl(20); + pub const GET_LINE_CONTROL: u32 = serial_ioctl(21); + pub const GET_CHARS: u32 = serial_ioctl(22); + pub const SET_CHARS: u32 = serial_ioctl(23); + pub const GET_HANDFLOW: u32 = serial_ioctl(24); + pub const SET_HANDFLOW: u32 = serial_ioctl(25); + pub const GET_MODEM_STATUS: u32 = serial_ioctl(26); + pub const GET_COMM_STATUS: u32 = serial_ioctl(27); + pub const GET_PROPERTIES: u32 = serial_ioctl(29); + pub const GET_DTR_RTS: u32 = serial_ioctl(30); + pub const GET_MODEM_CONTROL: u32 = serial_ioctl(37); + pub const SET_MODEM_CONTROL: u32 = serial_ioctl(38); + pub const SET_FIFO_CONTROL: u32 = serial_ioctl(39); +} + +/// Event bits used by `WaitCommEvent`. +pub mod event { + pub const RX_CHAR: u32 = 0x0001; + pub const RX_FLAG: u32 = 0x0002; + pub const TX_EMPTY: u32 = 0x0004; + pub const CTS: u32 = 0x0008; + pub const DSR: u32 = 0x0010; + pub const RLSD: u32 = 0x0020; + pub const BREAK: u32 = 0x0040; + pub const ERR: u32 = 0x0080; + pub const RING: u32 = 0x0100; + pub const RX_80_FULL: u32 = 0x0400; + + pub const SUPPORTED: u32 = + RX_CHAR | RX_FLAG | TX_EMPTY | CTS | DSR | RLSD | BREAK | ERR | RING | RX_80_FULL; +} + +/// Queue actions accepted by `PurgeComm`. +pub mod purge { + pub const TX_ABORT: u32 = 0x0000_0001; + pub const RX_ABORT: u32 = 0x0000_0002; + pub const TX_CLEAR: u32 = 0x0000_0004; + pub const RX_CLEAR: u32 = 0x0000_0008; + pub const ALL: u32 = TX_ABORT | RX_ABORT | TX_CLEAR | RX_CLEAR; +} + +/// Modem line bits returned by the serial IOCTLs. +pub mod modem { + pub const DTR: u32 = 0x0000_0001; + pub const RTS: u32 = 0x0000_0002; + pub const CTS: u32 = 0x0000_0010; + pub const DSR: u32 = 0x0000_0020; + pub const RI: u32 = 0x0000_0040; + pub const DCD: u32 = 0x0000_0080; + pub const OUTPUT: u32 = DTR | RTS; + pub const INPUT: u32 = CTS | DSR | RI | DCD; +} + +/// Baud-rate input/output buffer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(C)] +pub struct SerialBaudRate { + pub baud_rate: u32, +} + +/// Data format input/output buffer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(C)] +pub struct SerialLineControl { + pub stop_bits: u8, + pub parity: u8, + pub word_length: u8, +} + +/// Timeout input/output buffer used by the Windows serial API. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[repr(C)] +pub struct SerialTimeouts { + pub read_interval_timeout: u32, + pub read_total_timeout_multiplier: u32, + pub read_total_timeout_constant: u32, + pub write_total_timeout_multiplier: u32, + pub write_total_timeout_constant: u32, +} + +/// Requested driver queue sizes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(C)] +pub struct SerialQueueSize { + pub input_size: u32, + pub output_size: u32, +} + +/// Special serial characters. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(C)] +pub struct SerialChars { + pub eof: u8, + pub error: u8, + pub break_char: u8, + pub event: u8, + pub xon: u8, + pub xoff: u8, +} + +impl Default for SerialChars { + fn default() -> Self { + Self { + eof: 0, + error: 0, + break_char: 0, + event: 0, + xon: 0x11, + xoff: 0x13, + } + } +} + +/// Flow-control state. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[repr(C)] +pub struct SerialHandflow { + pub control_handshake: u32, + pub flow_replace: u32, + pub xon_limit: i32, + pub xoff_limit: i32, +} + +/// Queue and error status returned by `ClearCommError`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[repr(C)] +pub struct SerialStatus { + pub errors: u32, + pub hold_reasons: u32, + pub input_queue_bytes: u32, + pub output_queue_bytes: u32, + pub eof_received: u8, + pub waiting_for_immediate: u8, +} + +/// Capabilities returned by `GetCommProperties`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(C)] +pub struct SerialCommProperties { + pub packet_length: u16, + pub packet_version: u16, + pub service_mask: u32, + pub reserved: u32, + pub max_output_queue: u32, + pub max_input_queue: u32, + pub max_baud: u32, + pub provider_subtype: u32, + pub provider_capabilities: u32, + pub settable_parameters: u32, + pub settable_baud: u32, + pub settable_data: u16, + pub settable_stop_parity: u16, + pub current_output_queue: u32, + pub current_input_queue: u32, + pub provider_specific_1: u32, + pub provider_specific_2: u32, + pub provider_char: u16, +} + +/// Rejected serial configuration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SerialStateError { + BaudRate, + LineControl, + Timeouts, + QueueSize, + Chars, + Handflow, + WaitMask, + PurgeMask, +} + +/// Mutable state associated with one application COM port. +#[derive(Debug, Clone)] +pub struct SerialState { + baud_rate: u32, + line_control: SerialLineControl, + timeouts: SerialTimeouts, + chars: SerialChars, + handflow: SerialHandflow, + input_queue_limit: u32, + output_queue_limit: u32, + modem_output: u32, + modem_input: u32, + break_active: bool, + wait_mask: u32, + pending_events: u32, + errors: u32, + fifo_control: u32, +} + +impl SerialState { + /// Create a conventional 9600-8-N-1 virtual port state. + #[must_use] + pub const fn new(queue_limit: u32) -> Self { + Self { + baud_rate: 9_600, + line_control: SerialLineControl { + stop_bits: 0, + parity: 0, + word_length: 8, + }, + timeouts: SerialTimeouts { + read_interval_timeout: 0, + read_total_timeout_multiplier: 0, + read_total_timeout_constant: 0, + write_total_timeout_multiplier: 0, + write_total_timeout_constant: 0, + }, + chars: SerialChars { + eof: 0, + error: 0, + break_char: 0, + event: 0, + xon: 0x11, + xoff: 0x13, + }, + handflow: SerialHandflow { + control_handshake: 0, + flow_replace: 0, + xon_limit: 0, + xoff_limit: 0, + }, + input_queue_limit: queue_limit, + output_queue_limit: queue_limit, + modem_output: 0, + modem_input: modem::CTS | modem::DSR | modem::DCD, + break_active: false, + wait_mask: 0, + pending_events: 0, + errors: 0, + fifo_control: 0, + } + } + + #[must_use] + pub const fn baud_rate(&self) -> SerialBaudRate { + SerialBaudRate { + baud_rate: self.baud_rate, + } + } + + pub const fn set_baud_rate(&mut self, value: SerialBaudRate) -> Result<(), SerialStateError> { + if value.baud_rate == 0 { + return Err(SerialStateError::BaudRate); + } + self.baud_rate = value.baud_rate; + Ok(()) + } + + #[must_use] + pub const fn line_control(&self) -> SerialLineControl { + self.line_control + } + + pub const fn set_line_control( + &mut self, + value: SerialLineControl, + ) -> Result<(), SerialStateError> { + let word_length_valid = matches!(value.word_length, 5..=8); + let parity_valid = value.parity <= 4; + let stop_bits_valid = match value.stop_bits { + 0 => true, + 1 => value.word_length == 5, + 2 => value.word_length != 5, + _ => false, + }; + if !(word_length_valid && parity_valid && stop_bits_valid) { + return Err(SerialStateError::LineControl); + } + self.line_control = value; + Ok(()) + } + + #[must_use] + pub const fn timeouts(&self) -> SerialTimeouts { + self.timeouts + } + + pub const fn set_timeouts(&mut self, value: SerialTimeouts) -> Result<(), SerialStateError> { + if value.read_interval_timeout == u32::MAX + && value.read_total_timeout_multiplier == u32::MAX + && value.read_total_timeout_constant == u32::MAX + { + return Err(SerialStateError::Timeouts); + } + self.timeouts = value; + Ok(()) + } + + pub const fn set_queue_size(&self, value: SerialQueueSize) -> Result<(), SerialStateError> { + if value.input_size > self.input_queue_limit || value.output_size > self.output_queue_limit + { + return Err(SerialStateError::QueueSize); + } + Ok(()) + } + + #[must_use] + pub const fn chars(&self) -> SerialChars { + self.chars + } + + pub const fn set_chars(&mut self, value: SerialChars) -> Result<(), SerialStateError> { + if value.xon == value.xoff { + return Err(SerialStateError::Chars); + } + self.chars = value; + Ok(()) + } + + #[must_use] + pub const fn handflow(&self) -> SerialHandflow { + self.handflow + } + + pub fn set_handflow(&mut self, value: SerialHandflow) -> Result<(), SerialStateError> { + const CONTROL_INVALID: u32 = 0x7fff_ff84; + const FLOW_INVALID: u32 = 0x7fff_ff20; + let limits_valid = value.xon_limit >= 0 + && value.xoff_limit >= 0 + && u32::try_from(value.xon_limit).is_ok_and(|limit| limit <= self.input_queue_limit) + && u32::try_from(value.xoff_limit).is_ok_and(|limit| limit <= self.input_queue_limit); + if value.control_handshake & CONTROL_INVALID != 0 + || value.flow_replace & FLOW_INVALID != 0 + || !limits_valid + { + return Err(SerialStateError::Handflow); + } + self.handflow = value; + Ok(()) + } + + #[must_use] + pub const fn wait_mask(&self) -> u32 { + self.wait_mask + } + + pub const fn set_wait_mask(&mut self, value: u32) -> Result<(), SerialStateError> { + if value & !event::SUPPORTED != 0 { + return Err(SerialStateError::WaitMask); + } + self.wait_mask = value; + self.pending_events &= value; + Ok(()) + } + + /// Take events which currently satisfy the configured wait mask. + pub const fn take_wait_events(&mut self) -> Option { + let ready = self.pending_events & self.wait_mask; + if ready == 0 { + None + } else { + self.pending_events &= !ready; + Some(ready) + } + } + + /// Record bytes delivered to the application receive queue. + pub fn signal_receive(&mut self, bytes: &[u8], queue_bytes: usize) { + if bytes.is_empty() { + return; + } + self.pending_events |= event::RX_CHAR; + if bytes.contains(&self.chars.event) { + self.pending_events |= event::RX_FLAG; + } + let threshold = usize::try_from(self.input_queue_limit).unwrap_or(usize::MAX) * 4 / 5; + if queue_bytes >= threshold { + self.pending_events |= event::RX_80_FULL; + } + } + + pub const fn signal_transmit_empty(&mut self) { + self.pending_events |= event::TX_EMPTY; + } + + pub const fn set_break(&mut self, active: bool) { + if self.break_active != active { + self.break_active = active; + self.pending_events |= event::BREAK; + } + } + + pub const fn set_dtr(&mut self, active: bool) { + self.set_output_line(modem::DTR, active); + } + + pub const fn set_rts(&mut self, active: bool) { + self.set_output_line(modem::RTS, active); + } + + #[must_use] + pub const fn modem_output(&self) -> u32 { + self.modem_output + } + + pub const fn set_modem_output(&mut self, value: u32) { + self.modem_output = value & modem::OUTPUT; + } + + #[must_use] + pub const fn modem_input(&self) -> u32 { + self.modem_input + } + + pub const fn set_modem_input(&mut self, value: u32) { + let value = value & modem::INPUT; + let changed = self.modem_input ^ value; + self.modem_input = value; + if changed & modem::CTS != 0 { + self.pending_events |= event::CTS; + } + if changed & modem::DSR != 0 { + self.pending_events |= event::DSR; + } + if changed & modem::DCD != 0 { + self.pending_events |= event::RLSD; + } + if changed & modem::RI != 0 { + self.pending_events |= event::RING; + } + } + + pub const fn set_fifo_control(&mut self, value: u32) { + self.fifo_control = value; + } + + pub const fn validate_purge(value: u32) -> Result<(), SerialStateError> { + if value == 0 || value & !purge::ALL != 0 { + Err(SerialStateError::PurgeMask) + } else { + Ok(()) + } + } + + #[must_use] + pub fn status(&mut self, input_bytes: usize, output_bytes: usize) -> SerialStatus { + let status = SerialStatus { + errors: self.errors, + hold_reasons: 0, + input_queue_bytes: u32::try_from(input_bytes).unwrap_or(u32::MAX), + output_queue_bytes: u32::try_from(output_bytes).unwrap_or(u32::MAX), + eof_received: 0, + waiting_for_immediate: 0, + }; + self.errors = 0; + status + } + + #[must_use] + pub fn properties(&self) -> SerialCommProperties { + const PROVIDER_CAPABILITIES: u32 = 0x01ff; + const SETTABLE_PARAMETERS: u32 = 0x007f; + const SETTABLE_BAUD: u32 = 0x1007_ffff; + const SETTABLE_DATA: u16 = 0x000f; + const SETTABLE_STOP_PARITY: u16 = 0x1f07; + SerialCommProperties { + packet_length: u16::try_from(std::mem::size_of::()) + .unwrap_or(u16::MAX), + packet_version: 2, + service_mask: 1, + reserved: 0, + max_output_queue: self.output_queue_limit, + max_input_queue: self.input_queue_limit, + max_baud: 0x1000_0000, + provider_subtype: 1, + provider_capabilities: PROVIDER_CAPABILITIES, + settable_parameters: SETTABLE_PARAMETERS, + settable_baud: SETTABLE_BAUD, + settable_data: SETTABLE_DATA, + settable_stop_parity: SETTABLE_STOP_PARITY, + current_output_queue: self.output_queue_limit, + current_input_queue: self.input_queue_limit, + provider_specific_1: 0, + provider_specific_2: 0, + provider_char: 0, + } + } + + const fn set_output_line(&mut self, mask: u32, active: bool) { + if active { + self.modem_output |= mask; + } else { + self.modem_output &= !mask; + } + } +} + +impl Default for SerialState { + fn default() -> Self { + Self::new(64 * 1024) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serial_ioctl_values_match_ntddser() { + assert_eq!(ioctl::SET_BAUD_RATE, 0x001b_0004); + assert_eq!(ioctl::WAIT_ON_MASK, 0x001b_0048); + assert_eq!(ioctl::GET_PROPERTIES, 0x001b_0074); + } + + #[test] + fn validates_line_control_combinations() { + let mut state = SerialState::default(); + assert!( + state + .set_line_control(SerialLineControl { + stop_bits: 0, + parity: 0, + word_length: 8, + }) + .is_ok() + ); + assert_eq!( + state.set_line_control(SerialLineControl { + stop_bits: 1, + parity: 0, + word_length: 8, + }), + Err(SerialStateError::LineControl) + ); + assert_eq!( + state.set_line_control(SerialLineControl { + stop_bits: 2, + parity: 0, + word_length: 5, + }), + Err(SerialStateError::LineControl) + ); + } + + #[test] + fn rejects_windows_unsupported_all_maximum_read_timeouts() { + let mut state = SerialState::default(); + assert_eq!( + state.set_timeouts(SerialTimeouts { + read_interval_timeout: u32::MAX, + read_total_timeout_multiplier: u32::MAX, + read_total_timeout_constant: u32::MAX, + ..SerialTimeouts::default() + }), + Err(SerialStateError::Timeouts) + ); + } + + #[test] + fn wait_events_are_masked_and_consumed() { + let mut state = SerialState::default(); + state + .set_wait_mask(event::RX_CHAR | event::CTS) + .expect("mask"); + state.signal_receive(b"FA;", 3); + assert_eq!(state.take_wait_events(), Some(event::RX_CHAR)); + assert_eq!(state.take_wait_events(), None); + state.set_modem_input(modem::DSR | modem::DCD); + assert_eq!(state.take_wait_events(), Some(event::CTS)); + } + + #[test] + fn changing_wait_mask_discards_unrequested_events() { + let mut state = SerialState::default(); + state.set_wait_mask(event::RX_CHAR).expect("mask"); + state.signal_receive(b"x", 1); + state.set_wait_mask(event::CTS).expect("new mask"); + assert_eq!(state.take_wait_events(), None); + } + + #[test] + fn rejects_invalid_chars_and_handflow() { + let mut state = SerialState::default(); + assert_eq!( + state.set_chars(SerialChars { + xon: 1, + xoff: 1, + ..SerialChars::default() + }), + Err(SerialStateError::Chars) + ); + assert_eq!( + state.set_handflow(SerialHandflow { + xon_limit: -1, + ..SerialHandflow::default() + }), + Err(SerialStateError::Handflow) + ); + } + + #[test] + fn comm_status_returns_queue_depth_and_clears_errors() { + let mut state = SerialState::default(); + let status = state.status(17, 9); + assert_eq!(status.input_queue_bytes, 17); + assert_eq!(status.output_queue_bytes, 9); + assert_eq!(state.status(0, 0).errors, 0); + } +} From f5ecd53e5ef0f4a16ac1502e4bef2f95eb7a9df4 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 19:37:36 -0700 Subject: [PATCH 06/39] Implement the UMDF private transport protocol --- crates/cathub-virtual-serial/Cargo.toml | 17 +- crates/cathub-virtual-serial/src/lib.rs | 1 + drivers/cathub-virtual-serial-umdf/Cargo.lock | 16 +- drivers/cathub-virtual-serial-umdf/Cargo.toml | 1 + drivers/cathub-virtual-serial-umdf/README.md | 11 +- .../src/data_plane.rs | 30 + .../cathub-virtual-serial-umdf/src/interop.rs | 234 +++++- drivers/cathub-virtual-serial-umdf/src/lib.rs | 2 + .../src/private_protocol.rs | 717 ++++++++++++++++++ .../cathub-virtual-serial-umdf/src/serial.rs | 7 + 10 files changed, 987 insertions(+), 49 deletions(-) create mode 100644 drivers/cathub-virtual-serial-umdf/src/private_protocol.rs diff --git a/crates/cathub-virtual-serial/Cargo.toml b/crates/cathub-virtual-serial/Cargo.toml index d1fa487..a0e3078 100644 --- a/crates/cathub-virtual-serial/Cargo.toml +++ b/crates/cathub-virtual-serial/Cargo.toml @@ -9,14 +9,23 @@ repository.workspace = true readme = "README.md" publish = false +[features] +default = ["conformance"] +conformance = ["dep:clap", "dep:serde", "dep:serde_json", "dep:windows-sys"] + +[[bin]] +name = "serial-conformance" +path = "src/bin/serial-conformance.rs" +required-features = ["conformance"] + [dependencies] -clap = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } +clap = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } thiserror = { workspace = true } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59", features = [ +windows-sys = { version = "0.59", optional = true, features = [ "Win32_Devices_Communication", "Win32_Foundation", "Win32_Security", diff --git a/crates/cathub-virtual-serial/src/lib.rs b/crates/cathub-virtual-serial/src/lib.rs index 079164b..6c11c5a 100644 --- a/crates/cathub-virtual-serial/src/lib.rs +++ b/crates/cathub-virtual-serial/src/lib.rs @@ -5,5 +5,6 @@ #![allow(clippy::doc_markdown)] +#[cfg(feature = "conformance")] pub mod conformance; pub mod protocol; diff --git a/drivers/cathub-virtual-serial-umdf/Cargo.lock b/drivers/cathub-virtual-serial-umdf/Cargo.lock index 03fb74c..c616618 100644 --- a/drivers/cathub-virtual-serial-umdf/Cargo.lock +++ b/drivers/cathub-virtual-serial-umdf/Cargo.lock @@ -125,10 +125,18 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cathub-virtual-serial" +version = "0.2.1" +dependencies = [ + "thiserror", +] + [[package]] name = "cathub-virtual-serial-umdf" version = "0.0.1" dependencies = [ + "cathub-virtual-serial", "wdk", "wdk-build", "wdk-sys", @@ -136,9 +144,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.3" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "shlex 2.0.1", @@ -228,9 +236,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "errno" diff --git a/drivers/cathub-virtual-serial-umdf/Cargo.toml b/drivers/cathub-virtual-serial-umdf/Cargo.toml index 789274b..84ccbd6 100644 --- a/drivers/cathub-virtual-serial-umdf/Cargo.toml +++ b/drivers/cathub-virtual-serial-umdf/Cargo.toml @@ -21,6 +21,7 @@ crate-type = ["cdylib"] wdk-build = { git = "https://github.com/microsoft/windows-drivers-rs", rev = "8e88dd899d9fa988df841e08cc01e9f663e5a415" } [dependencies] +cathub-virtual-serial = { path = "../../crates/cathub-virtual-serial", default-features = false } wdk = { git = "https://github.com/microsoft/windows-drivers-rs", rev = "8e88dd899d9fa988df841e08cc01e9f663e5a415" } wdk-sys = { git = "https://github.com/microsoft/windows-drivers-rs", rev = "8e88dd899d9fa988df841e08cc01e9f663e5a415" } diff --git a/drivers/cathub-virtual-serial-umdf/README.md b/drivers/cathub-virtual-serial-umdf/README.md index 2eed6f6..7e05d96 100644 --- a/drivers/cathub-virtual-serial-umdf/README.md +++ b/drivers/cathub-virtual-serial-umdf/README.md @@ -87,6 +87,11 @@ queue status, purge, immediate characters, and `WaitCommEvent`. Unsupported IOCT Each WDF device owns its transport state and pending-read queues through typed object context, and the context's destroy callback releases the Rust-owned state during device teardown. -The private daemon handle remains a raw-byte channel. It does not yet decode the shared framing -contract or restrict the interface to a service SID. The application COM path and driver package -must still be installed and verified on the isolated driver-development target. +The private daemon handle implements the shared `CHVS` 1.0 framing contract, including version +negotiation, discovery, attach/detach, lifecycle events, framed data, receive credit, serial and +modem events, purge, health checks, and deterministic protocol rejection. Its application data is +kept separate from driver control frames. + +The private interface is not yet restricted to a service SID. The application COM path, private +CatHub adapter, and driver package must still be verified together on the isolated +driver-development target. diff --git a/drivers/cathub-virtual-serial-umdf/src/data_plane.rs b/drivers/cathub-virtual-serial-umdf/src/data_plane.rs index 18ddcc7..2221a3e 100644 --- a/drivers/cathub-virtual-serial-umdf/src/data_plane.rs +++ b/drivers/cathub-virtual-serial-umdf/src/data_plane.rs @@ -119,6 +119,25 @@ impl EndpointDataPlane { Ok(bytes.len()) } + /// Append one driver-generated private frame for the connected daemon. + pub fn write_to_daemon(&mut self, bytes: &[u8]) -> Result { + self.require_open(ChannelRole::Daemon)?; + self.application_to_daemon.try_push(bytes)?; + Ok(bytes.len()) + } + + /// Read private driver frames without requiring an application COM handle. + pub fn read_for_daemon(&mut self, output: &mut [u8]) -> Result { + self.require_open(ChannelRole::Daemon)?; + Ok(self.application_to_daemon.pop_into(output)) + } + + /// Return private bytes waiting for the daemon. + pub fn available_for_daemon(&self) -> Result { + self.require_open(ChannelRole::Daemon)?; + Ok(self.application_to_daemon.len()) + } + /// Remove up to `output.len()` bytes waiting for the supplied role. /// /// # Errors @@ -356,4 +375,15 @@ mod tests { plane.clear_outgoing(ChannelRole::Application); assert_eq!(plane.outgoing_len(ChannelRole::Application), 0); } + + #[test] + fn driver_control_frames_do_not_require_application_open() { + let mut plane = EndpointDataPlane::new(32); + plane.open(ChannelRole::Daemon).expect("daemon"); + plane.write_to_daemon(b"hello").expect("control frame"); + assert_eq!(plane.available_for_daemon(), Ok(5)); + let mut output = [0_u8; 8]; + assert_eq!(plane.read_for_daemon(&mut output), Ok(5)); + assert_eq!(&output[..5], b"hello"); + } } diff --git a/drivers/cathub-virtual-serial-umdf/src/interop.rs b/drivers/cathub-virtual-serial-umdf/src/interop.rs index 27f7866..d19f48a 100644 --- a/drivers/cathub-virtual-serial-umdf/src/interop.rs +++ b/drivers/cathub-virtual-serial-umdf/src/interop.rs @@ -23,6 +23,7 @@ use wdk_sys::{ }; use crate::data_plane::{ChannelRole, DEFAULT_BUFFER_CAPACITY, DataPlaneError, EndpointDataPlane}; +use crate::private_protocol::{DriverProtocol, DriverProtocolError, ProtocolOutput}; use crate::serial::{ SerialBaudRate, SerialChars, SerialCommProperties, SerialHandflow, SerialLineControl, SerialQueueSize, SerialState, SerialStateError, SerialStatus, SerialTimeouts, ioctl, purge, @@ -68,6 +69,7 @@ struct DeviceContext { struct DeviceState { channel: Mutex, + protocol: Mutex, serial: Mutex, application_reads: AtomicPtr, daemon_reads: AtomicPtr, @@ -80,6 +82,7 @@ impl DeviceState { serial.set_modem_input(serial.modem_input()); Self { channel: Mutex::new(ChannelState::new()), + protocol: Mutex::new(DriverProtocol::new()), serial: Mutex::new(serial), application_reads: AtomicPtr::new(ptr::null_mut()), daemon_reads: AtomicPtr::new(ptr::null_mut()), @@ -106,6 +109,12 @@ impl DeviceState { .unwrap_or_else(std::sync::PoisonError::into_inner) } + fn protocol(&self) -> MutexGuard<'_, DriverProtocol> { + self.protocol + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + fn wait_queue(&self) -> WDFQUEUE { self.wait_requests.load(Ordering::Acquire) } @@ -475,6 +484,25 @@ unsafe extern "C" fn evt_device_file_create( let open_result = state.channel().open(role, file); match open_result { Ok(session) => { + let outputs = match role { + ChannelRole::Application => state.protocol().application_opened(session), + ChannelRole::Daemon => { + state.protocol().reset_daemon(); + Ok(Vec::new()) + } + }; + let outputs = match outputs { + Ok(outputs) => outputs, + Err(error) => { + state.channel().close_if_owner(role, file); + return RequestDisposition::error(status_for_protocol_error(error)); + } + }; + // SAFETY: This callback owns the create request and all device queues are live. + if let Err(error) = unsafe { apply_protocol_outputs(state, outputs) } { + state.channel().close_if_owner(role, file); + return RequestDisposition::error(status_for_error(error)); + } println!("CatHub PoC {role:?} handle opened (session {session})"); RequestDisposition::success(0) } @@ -496,8 +524,20 @@ unsafe extern "C" fn evt_file_cleanup(file: WDFFILEOBJECT) { let Some(role) = (unsafe { role_from_file(file) }) else { return; }; + if !state.channel().owns(role, file) { + return; + } + let outputs = match role { + ChannelRole::Application => state.protocol().application_closed().unwrap_or_default(), + ChannelRole::Daemon => { + state.protocol().reset_daemon(); + Vec::new() + } + }; if state.channel().close_if_owner(role, file) { println!("CatHub PoC {role:?} handle cleaned up"); + // SAFETY: Cleanup runs while the device and its child queues are alive. + let _ = unsafe { apply_protocol_outputs(state, outputs) }; // SAFETY: This handle refers to a manual queue owned by this device. unsafe { drain_pending_reads(state.queue_for(ChannelRole::Application), STATUS_CANCELLED); @@ -578,7 +618,10 @@ unsafe fn handle_read( if length == 0 { return RequestDisposition::success(0); } - let available = state.channel().plane.available_to_read(role); + let available = match role { + ChannelRole::Application => state.channel().plane.available_to_read(role), + ChannelRole::Daemon => state.channel().plane.available_for_daemon(), + }; match available { Ok(0) => { let queue = state.queue_for(role); @@ -634,20 +677,26 @@ unsafe fn handle_write( let usable = length.min(buffer_length); // SAFETY: WDF returned at least `buffer_length` readable bytes. let input = unsafe { slice::from_raw_parts(buffer.cast_const().cast::(), usable) }; - let write_result = state.channel().plane.write(role, input); - match write_result { - Ok(written) => { + let outputs = match role { + ChannelRole::Application => state + .protocol() + .application_data(input) + .map(|output| vec![output]), + ChannelRole::Daemon => state.protocol().ingest_daemon(input), + }; + let outputs = match outputs { + Ok(outputs) => outputs, + Err(error) => return RequestDisposition::error(status_for_protocol_error(error)), + }; + // SAFETY: The invoking callback owns the request and device child queues are live. + match unsafe { apply_protocol_outputs(state, outputs) } { + Ok(()) => { if role == ChannelRole::Application { state.serial().signal_transmit_empty(); - } else { - let queued = state.channel().plane.incoming_len(ChannelRole::Application); - state.serial().signal_receive(input, queued); + // SAFETY: The wait-request queue belongs to this device. + unsafe { service_wait_request(state) }; } - // SAFETY: The wait-request queue belongs to this device. - unsafe { service_wait_request(state) }; - // SAFETY: The peer's pending queue belongs to this device. - unsafe { service_pending_reads(state, role.peer()) }; - RequestDisposition::success(written) + RequestDisposition::success(usable) } Err(error) => RequestDisposition::error(status_for_error(error)), } @@ -721,27 +770,27 @@ unsafe fn handle_device_control( } ioctl::SET_BREAK_ON => { state.serial().set_break(true); - RequestDisposition::success(0) + emit_modem_control(state) } ioctl::SET_BREAK_OFF => { state.serial().set_break(false); - RequestDisposition::success(0) + emit_modem_control(state) } ioctl::SET_DTR => { state.serial().set_dtr(true); - RequestDisposition::success(0) + emit_modem_control(state) } ioctl::CLR_DTR => { state.serial().set_dtr(false); - RequestDisposition::success(0) + emit_modem_control(state) } ioctl::SET_RTS => { state.serial().set_rts(true); - RequestDisposition::success(0) + emit_modem_control(state) } ioctl::CLR_RTS => { state.serial().set_rts(false); - RequestDisposition::success(0) + emit_modem_control(state) } ioctl::GET_DTR_RTS | ioctl::GET_MODEM_CONTROL => { // SAFETY: The output buffer receives one documented 32-bit serial line bitmap. @@ -752,7 +801,7 @@ unsafe fn handle_device_control( match unsafe { request_input::(request) } { Ok(value) => { state.serial().set_modem_output(value); - RequestDisposition::success(0) + emit_modem_control(state) } Err(status) => RequestDisposition::error(status), } @@ -824,11 +873,50 @@ fn serial_set( }; let set_result = setter(&mut state.serial(), value); match set_result { - Ok(()) => RequestDisposition::success(0), + Ok(()) => emit_serial_config(state), Err(error) => RequestDisposition::error(status_for_serial_error(error)), } } +fn emit_serial_config(state: &DeviceState) -> RequestDisposition { + let (baud, line, timeouts) = { + let serial = state.serial(); + (serial.baud_rate(), serial.line_control(), serial.timeouts()) + }; + let output = state.protocol().serial_config( + baud.baud_rate, + line.word_length, + line.parity, + line.stop_bits, + timeouts.read_total_timeout_constant, + timeouts.write_total_timeout_constant, + ); + apply_optional_protocol_output(state, output) +} + +fn emit_modem_control(state: &DeviceState) -> RequestDisposition { + let mask = state.serial().modem_control_mask(); + let output = state.protocol().modem_control(mask); + apply_optional_protocol_output(state, output) +} + +fn apply_optional_protocol_output( + state: &DeviceState, + output: Result, DriverProtocolError>, +) -> RequestDisposition { + match output { + Ok(Some(output)) => { + // SAFETY: Every caller runs inside a live WDF device callback. + match unsafe { apply_protocol_outputs(state, vec![output]) } { + Ok(()) => RequestDisposition::success(0), + Err(error) => RequestDisposition::error(status_for_error(error)), + } + } + Ok(None) => RequestDisposition::success(0), + Err(error) => RequestDisposition::error(status_for_protocol_error(error)), + } +} + unsafe fn request_input(request: WDFREQUEST) -> Result { let mut buffer: PVOID = ptr::null_mut(); let mut length = 0; @@ -980,6 +1068,17 @@ unsafe fn purge_queues(state: &DeviceState, request: WDFREQUEST) -> RequestDispo // SAFETY: This manual queue belongs to the application side of this device. unsafe { drain_pending_reads(state.queue_for(ChannelRole::Application), STATUS_CANCELLED) }; } + let purge_output = state.protocol().purge(mask); + match purge_output { + Ok(Some(output)) => { + // SAFETY: The daemon queue belongs to this live device. + if let Err(error) = unsafe { apply_protocol_outputs(state, vec![output]) } { + return RequestDisposition::error(status_for_error(error)); + } + } + Ok(None) => {} + Err(error) => return RequestDisposition::error(status_for_protocol_error(error)), + } RequestDisposition::success(0) } @@ -989,18 +1088,17 @@ unsafe fn immediate_char(state: &DeviceState, request: WDFREQUEST) -> RequestDis Ok(byte) => byte, Err(status) => return RequestDisposition::error(status), }; - let write_result = state - .channel() - .plane - .write(ChannelRole::Application, &[byte]); - match write_result { - Ok(_) => { + let output = state.protocol().application_data(&[byte]); + match output { + Ok(output) => { state.serial().signal_transmit_empty(); - // SAFETY: The daemon read queue belongs to this device. - unsafe { service_pending_reads(state, ChannelRole::Daemon) }; - RequestDisposition::success(0) + // SAFETY: The daemon queue belongs to this live device. + match unsafe { apply_protocol_outputs(state, vec![output]) } { + Ok(()) => RequestDisposition::success(0), + Err(error) => RequestDisposition::error(status_for_error(error)), + } } - Err(error) => RequestDisposition::error(status_for_error(error)), + Err(error) => RequestDisposition::error(status_for_protocol_error(error)), } } @@ -1028,22 +1126,66 @@ unsafe fn read_available( let usable = requested.min(buffer_length); // SAFETY: WDF returned at least `buffer_length` writable bytes. let output = unsafe { slice::from_raw_parts_mut(buffer.cast::(), usable) }; - let read_result = state.channel().plane.read(role, output); + let read_result = match role { + ChannelRole::Application => state.channel().plane.read(role, output), + ChannelRole::Daemon => state.channel().plane.read_for_daemon(output), + }; match read_result { - Ok(read) => RequestDisposition::success(read), + Ok(read) => { + if role == ChannelRole::Application { + let update = state.protocol().application_bytes_released(read); + if let Ok(Some(update)) = update { + // SAFETY: Device child queues remain live for the duration of this callback. + let _ = unsafe { apply_protocol_outputs(state, vec![update]) }; + } + } + RequestDisposition::success(read) + } Err(error) => RequestDisposition::error(status_for_error(error)), } } +unsafe fn apply_protocol_outputs( + state: &DeviceState, + outputs: Vec, +) -> Result<(), DataPlaneError> { + for output in outputs { + match output { + ProtocolOutput::ToDaemon(bytes) => { + state.channel().plane.write_to_daemon(&bytes)?; + // SAFETY: The daemon read queue belongs to this device. + unsafe { service_pending_reads(state, ChannelRole::Daemon) }; + } + ProtocolOutput::ToApplication(bytes) => { + let queued = { + let mut channel = state.channel(); + channel.plane.write(ChannelRole::Daemon, &bytes)?; + channel.plane.incoming_len(ChannelRole::Application) + }; + state.serial().signal_receive(&bytes, queued); + // SAFETY: The application read queue belongs to this device. + unsafe { service_pending_reads(state, ChannelRole::Application) }; + // SAFETY: The wait-request queue belongs to this device. + unsafe { service_wait_request(state) }; + } + ProtocolOutput::ModemStatus(mask) => { + state.serial().set_modem_input(mask); + // SAFETY: The wait-request queue belongs to this device. + unsafe { service_wait_request(state) }; + } + } + } + Ok(()) +} + unsafe fn service_pending_reads(state: &DeviceState, role: ChannelRole) { let queue = state.queue_for(role); loop { - if state - .channel() - .plane - .available_to_read(role) - .map_or(true, |available| available == 0) - { + let available = match role { + ChannelRole::Application => state.channel().plane.available_to_read(role), + ChannelRole::Daemon => state.channel().plane.available_for_daemon(), + }; + if available.map_or(true, |available| available == 0) { break; } let mut request: WDFREQUEST = ptr::null_mut(); @@ -1255,6 +1397,22 @@ const fn status_for_serial_error(_error: SerialStateError) -> NTSTATUS { STATUS_INVALID_PARAMETER } +const fn status_for_protocol_error(error: DriverProtocolError) -> NTSTATUS { + match error { + DriverProtocolError::NotReady + | DriverProtocolError::NotAttached + | DriverProtocolError::Session => STATUS_DEVICE_NOT_CONNECTED, + DriverProtocolError::Credit | DriverProtocolError::ReceiveWindow => STATUS_BUFFER_OVERFLOW, + DriverProtocolError::Framing + | DriverProtocolError::Version + | DriverProtocolError::Features + | DriverProtocolError::FrameLimit + | DriverProtocolError::WrongEndpoint + | DriverProtocolError::Sequence + | DriverProtocolError::Encoding => STATUS_INVALID_PARAMETER, + } +} + fn struct_size() -> ULONG { u32::try_from(size_of::()).unwrap_or(ULONG::MAX) } diff --git a/drivers/cathub-virtual-serial-umdf/src/lib.rs b/drivers/cathub-virtual-serial-umdf/src/lib.rs index 6ad1e5a..51e10ae 100644 --- a/drivers/cathub-virtual-serial-umdf/src/lib.rs +++ b/drivers/cathub-virtual-serial-umdf/src/lib.rs @@ -12,4 +12,6 @@ mod data_plane; #[cfg_attr(test, allow(dead_code))] mod interop; #[cfg_attr(test, allow(dead_code))] +mod private_protocol; +#[cfg_attr(test, allow(dead_code))] mod serial; diff --git a/drivers/cathub-virtual-serial-umdf/src/private_protocol.rs b/drivers/cathub-virtual-serial-umdf/src/private_protocol.rs new file mode 100644 index 0000000..888bbb4 --- /dev/null +++ b/drivers/cathub-virtual-serial-umdf/src/private_protocol.rs @@ -0,0 +1,717 @@ +//! Safe implementation of the private `CatHub` driver protocol. + +use cathub_virtual_serial::protocol::{ + ABSOLUTE_MAX_FRAME_LEN, CURRENT_VERSION, Frame, FrameDecoder, FrameFlags, MessageKind, + ProtocolError, ProtocolVersion, control_field, data_field, endpoint_field, feature, + hello_field, serial_field, session_field, +}; + +use crate::data_plane::DEFAULT_BUFFER_CAPACITY; + +/// The first driver package exposes one endpoint per device instance. +pub const ENDPOINT_ID: u64 = 1; +const ENDPOINT_KIND_CAT: u16 = 1; +const ENDPOINT_STABLE_ID: &str = "cathub-default"; +const ENDPOINT_DISPLAY_NAME: &str = "CatHub Virtual Serial Port"; +const DRIVER_IDENTITY: &str = "cathub-umdf"; +const REQUIRED_FEATURES: u64 = feature::APPLICATION_LIFECYCLE + | feature::SERIAL_CONFIG + | feature::MODEM_LINES + | feature::CANCELLATION + | feature::PURGE + | feature::HEALTH + | feature::RECEIVE_CREDIT; + +/// One effect emitted while processing private protocol traffic. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProtocolOutput { + /// Encoded frame to make available to the `CatHub` daemon. + ToDaemon(Vec), + /// Opaque serial bytes to make available to the application COM handle. + ToApplication(Vec), + /// New CTS/DSR/DCD/RI input-line bitmap. + ModemStatus(u32), +} + +/// Fatal private-channel error. The daemon must reconnect after this result. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DriverProtocolError { + Framing, + Version, + Features, + FrameLimit, + ReceiveWindow, + WrongEndpoint, + NotReady, + NotAttached, + Session, + Sequence, + Credit, + Encoding, +} + +impl From for DriverProtocolError { + fn from(_error: ProtocolError) -> Self { + Self::Framing + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProtocolPhase { + AwaitHello, + Ready, +} + +/// Protocol state owned by one driver device instance. +#[derive(Debug)] +pub struct DriverProtocol { + decoder: FrameDecoder, + phase: ProtocolPhase, + attached: bool, + application_open: bool, + application_session: u64, + negotiated_frame_limit: usize, + daemon_receive_limit: usize, + daemon_receive_credit: usize, + driver_receive_credit: usize, + outbound_sequence: u64, + inbound_sequence: u64, +} + +impl DriverProtocol { + /// Create a disconnected protocol endpoint. + #[must_use] + pub fn new() -> Self { + Self { + decoder: FrameDecoder::default(), + phase: ProtocolPhase::AwaitHello, + attached: false, + application_open: false, + application_session: 0, + negotiated_frame_limit: ABSOLUTE_MAX_FRAME_LEN, + daemon_receive_limit: 0, + daemon_receive_credit: 0, + driver_receive_credit: DEFAULT_BUFFER_CAPACITY, + outbound_sequence: 0, + inbound_sequence: 0, + } + } + + /// Reset negotiation when the private daemon handle changes. + pub fn reset_daemon(&mut self) { + let application_open = self.application_open; + let application_session = self.application_session; + *self = Self::new(); + self.application_open = application_open; + self.application_session = application_session; + } + + /// Record a new application COM session and emit its lifecycle event when attached. + pub fn application_opened( + &mut self, + session: u64, + ) -> Result, DriverProtocolError> { + self.application_open = true; + self.application_session = session; + if self.attached { + Ok(vec![ + self.application_lifecycle(MessageKind::ApplicationOpen)?, + ]) + } else { + Ok(Vec::new()) + } + } + + /// Record application cleanup and emit a close event when attached. + pub fn application_closed(&mut self) -> Result, DriverProtocolError> { + let outputs = if self.attached && self.application_open { + vec![self.application_lifecycle(MessageKind::ApplicationClose)?] + } else { + Vec::new() + }; + self.application_open = false; + Ok(outputs) + } + + /// Encode application bytes for the attached daemon. + pub fn application_data( + &mut self, + bytes: &[u8], + ) -> Result { + self.require_attached()?; + if !self.application_open { + return Err(DriverProtocolError::Session); + } + if bytes.len() > self.daemon_receive_credit { + return Err(DriverProtocolError::Credit); + } + self.outbound_sequence = self.outbound_sequence.wrapping_add(1).max(1); + let mut frame = Frame::new(MessageKind::Data, ENDPOINT_ID, 0); + frame + .fields + .insert_u64(data_field::SEQUENCE, self.outbound_sequence)?; + frame.fields.insert(data_field::BYTES, bytes)?; + let encoded = encode_frame(&frame, self.negotiated_frame_limit)?; + self.daemon_receive_credit -= bytes.len(); + Ok(encoded) + } + + /// Restore daemon-to-driver receive credit after application bytes are released. + pub fn application_bytes_released( + &mut self, + count: usize, + ) -> Result, DriverProtocolError> { + if count == 0 || !self.attached { + return Ok(None); + } + self.driver_receive_credit = self + .driver_receive_credit + .saturating_add(count) + .min(DEFAULT_BUFFER_CAPACITY); + let credit = u32::try_from(count).map_err(|_| DriverProtocolError::Credit)?; + let mut frame = Frame::new(MessageKind::WindowUpdate, ENDPOINT_ID, 0); + frame.fields.insert_u32(data_field::CREDIT, credit)?; + encode_frame(&frame, self.negotiated_frame_limit).map(Some) + } + + /// Consume an arbitrary stream fragment written by the daemon. + pub fn ingest_daemon( + &mut self, + bytes: &[u8], + ) -> Result, DriverProtocolError> { + if self.decoder.buffered_len().saturating_add(bytes.len()) > ABSOLUTE_MAX_FRAME_LEN { + return Err(DriverProtocolError::FrameLimit); + } + self.decoder.push(bytes); + let mut outputs = Vec::new(); + while let Some(frame) = self.decoder.next_frame()? { + if self.phase == ProtocolPhase::Ready + && (frame.version != CURRENT_VERSION + || frame + .encode() + .map_err(|_| DriverProtocolError::Encoding)? + .len() + > self.negotiated_frame_limit) + { + return Err(DriverProtocolError::Version); + } + outputs.extend(self.process_frame(&frame)?); + } + Ok(outputs) + } + + /// Emit the current serial configuration to an attached daemon. + pub fn serial_config( + &self, + baud: u32, + data_bits: u8, + parity: u8, + stop_bits: u8, + read_timeout_ms: u32, + write_timeout_ms: u32, + ) -> Result, DriverProtocolError> { + if !self.attached { + return Ok(None); + } + let mut frame = Frame::new(MessageKind::SerialConfig, ENDPOINT_ID, 0); + frame.fields.insert_u32(serial_field::BAUD, baud)?; + frame + .fields + .insert_u16(serial_field::DATA_BITS, u16::from(data_bits))?; + frame + .fields + .insert_u16(serial_field::PARITY, u16::from(parity))?; + frame + .fields + .insert_u16(serial_field::STOP_BITS, u16::from(stop_bits))?; + frame.fields.insert_u16(serial_field::FLOW_CONTROL, 0)?; + frame + .fields + .insert_u32(serial_field::READ_TIMEOUT_MS, read_timeout_ms)?; + frame + .fields + .insert_u32(serial_field::WRITE_TIMEOUT_MS, write_timeout_ms)?; + encode_frame(&frame, self.negotiated_frame_limit).map(Some) + } + + /// Emit current DTR/RTS/break state to an attached daemon. + pub fn modem_control(&self, mask: u32) -> Result, DriverProtocolError> { + if !self.attached { + return Ok(None); + } + let mut frame = Frame::new(MessageKind::ModemControl, ENDPOINT_ID, 0); + frame.fields.insert_u32(control_field::MASK, mask)?; + encode_frame(&frame, self.negotiated_frame_limit).map(Some) + } + + /// Emit a purge request to an attached daemon. + pub fn purge(&self, mask: u32) -> Result, DriverProtocolError> { + if !self.attached { + return Ok(None); + } + let mut frame = Frame::new(MessageKind::Purge, ENDPOINT_ID, 0); + frame.fields.insert_u32(control_field::MASK, mask)?; + encode_frame(&frame, self.negotiated_frame_limit).map(Some) + } + + fn process_frame(&mut self, frame: &Frame) -> Result, DriverProtocolError> { + if frame.kind != MessageKind::Hello && self.phase != ProtocolPhase::Ready { + return Err(DriverProtocolError::NotReady); + } + match frame.kind { + MessageKind::Hello => self.process_hello(frame), + MessageKind::Discover => self.process_discover(frame), + MessageKind::Attach => self.process_attach(frame), + MessageKind::Detach => { + Self::require_endpoint(frame)?; + self.attached = false; + Ok(Vec::new()) + } + MessageKind::Data => self.process_data(frame), + MessageKind::WindowUpdate => self.process_window_update(frame), + MessageKind::ModemStatus => self.process_modem_status(frame), + MessageKind::Health => self.process_health(frame), + _ => self.error_response(frame, 1, "message is not valid daemon input"), + } + } + + fn process_hello(&mut self, frame: &Frame) -> Result, DriverProtocolError> { + if frame.endpoint_id != 0 { + return Err(DriverProtocolError::WrongEndpoint); + } + let peer_min = ProtocolVersion { + major: frame.fields.require_u16(hello_field::MIN_MAJOR)?, + minor: frame.fields.require_u16(hello_field::MIN_MINOR)?, + }; + let peer_max = ProtocolVersion { + major: frame.fields.require_u16(hello_field::MAX_MAJOR)?, + minor: frame.fields.require_u16(hello_field::MAX_MINOR)?, + }; + let Some(version) = + ProtocolVersion::negotiate(CURRENT_VERSION, CURRENT_VERSION, peer_min, peer_max) + else { + return Err(DriverProtocolError::Version); + }; + let features = frame.fields.require_u64(hello_field::FEATURES)?; + if features & REQUIRED_FEATURES != REQUIRED_FEATURES { + return Err(DriverProtocolError::Features); + } + let peer_frame_limit = + usize::try_from(frame.fields.require_u32(hello_field::MAX_FRAME_BYTES)?) + .map_err(|_| DriverProtocolError::FrameLimit)?; + if peer_frame_limit < 64 { + return Err(DriverProtocolError::FrameLimit); + } + let receive_window = usize::try_from( + frame + .fields + .require_u32(hello_field::RECEIVE_WINDOW_BYTES)?, + ) + .map_err(|_| DriverProtocolError::ReceiveWindow)?; + if receive_window == 0 { + return Err(DriverProtocolError::ReceiveWindow); + } + self.phase = ProtocolPhase::Ready; + self.attached = false; + self.negotiated_frame_limit = peer_frame_limit.min(ABSOLUTE_MAX_FRAME_LEN); + self.daemon_receive_limit = receive_window.min(DEFAULT_BUFFER_CAPACITY); + self.daemon_receive_credit = self.daemon_receive_limit; + self.driver_receive_credit = DEFAULT_BUFFER_CAPACITY; + self.outbound_sequence = 0; + self.inbound_sequence = 0; + + let mut response = response_frame(MessageKind::HelloAck, frame, true); + response.version = version; + response + .fields + .insert_u16(hello_field::MIN_MAJOR, version.major)?; + response + .fields + .insert_u16(hello_field::MIN_MINOR, version.minor)?; + response + .fields + .insert_u16(hello_field::MAX_MAJOR, version.major)?; + response + .fields + .insert_u16(hello_field::MAX_MINOR, version.minor)?; + response.fields.insert_u32( + hello_field::MAX_FRAME_BYTES, + u32::try_from(self.negotiated_frame_limit) + .map_err(|_| DriverProtocolError::FrameLimit)?, + )?; + response.fields.insert_u32( + hello_field::RECEIVE_WINDOW_BYTES, + u32::try_from(DEFAULT_BUFFER_CAPACITY) + .map_err(|_| DriverProtocolError::ReceiveWindow)?, + )?; + response + .fields + .insert_u64(hello_field::FEATURES, REQUIRED_FEATURES)?; + response + .fields + .insert_string(hello_field::IDENTITY, DRIVER_IDENTITY)?; + Ok(vec![encode_frame(&response, self.negotiated_frame_limit)?]) + } + + fn process_discover(&self, frame: &Frame) -> Result, DriverProtocolError> { + if frame.endpoint_id != 0 { + return Err(DriverProtocolError::WrongEndpoint); + } + let mut endpoint = response_frame(MessageKind::Endpoint, frame, false); + endpoint.endpoint_id = ENDPOINT_ID; + endpoint + .fields + .insert_u16(endpoint_field::KIND, ENDPOINT_KIND_CAT)?; + endpoint + .fields + .insert_string(endpoint_field::STABLE_ID, ENDPOINT_STABLE_ID)?; + endpoint + .fields + .insert_string(endpoint_field::DISPLAY_NAME, ENDPOINT_DISPLAY_NAME)?; + endpoint.fields.insert_u16(endpoint_field::ENABLED, 1)?; + let complete = response_frame(MessageKind::DiscoverComplete, frame, true); + Ok(vec![ + encode_frame(&endpoint, self.negotiated_frame_limit)?, + encode_frame(&complete, self.negotiated_frame_limit)?, + ]) + } + + fn process_attach( + &mut self, + frame: &Frame, + ) -> Result, DriverProtocolError> { + Self::require_endpoint(frame)?; + let requested_session = frame.fields.require_u64(session_field::SESSION_ID)?; + if requested_session != 0 + && self.application_open + && requested_session != self.application_session + { + return Err(DriverProtocolError::Session); + } + let credit = usize::try_from(frame.fields.require_u32(session_field::RECEIVE_CREDIT)?) + .map_err(|_| DriverProtocolError::Credit)?; + if credit == 0 { + return Err(DriverProtocolError::Credit); + } + self.daemon_receive_limit = credit.min(DEFAULT_BUFFER_CAPACITY); + self.daemon_receive_credit = self.daemon_receive_limit; + self.driver_receive_credit = DEFAULT_BUFFER_CAPACITY; + self.attached = true; + self.outbound_sequence = 0; + self.inbound_sequence = 0; + + let mut response = response_frame(MessageKind::AttachAck, frame, true); + response + .fields + .insert_u64(session_field::SESSION_ID, self.application_session)?; + response.fields.insert_u32( + session_field::RECEIVE_CREDIT, + u32::try_from(DEFAULT_BUFFER_CAPACITY).map_err(|_| DriverProtocolError::Credit)?, + )?; + let mut output = vec![encode_frame(&response, self.negotiated_frame_limit)?]; + if self.application_open { + output.push(self.application_lifecycle(MessageKind::ApplicationOpen)?); + } + Ok(output) + } + + fn process_data(&mut self, frame: &Frame) -> Result, DriverProtocolError> { + self.require_attached()?; + Self::require_endpoint(frame)?; + if !self.application_open { + return Err(DriverProtocolError::Session); + } + let sequence = frame.fields.require_u64(data_field::SEQUENCE)?; + if sequence <= self.inbound_sequence { + return Err(DriverProtocolError::Sequence); + } + let bytes = frame + .fields + .get(data_field::BYTES) + .ok_or(ProtocolError::MissingField(data_field::BYTES))?; + if bytes.len() > self.driver_receive_credit { + return Err(DriverProtocolError::Credit); + } + self.inbound_sequence = sequence; + self.driver_receive_credit -= bytes.len(); + Ok(vec![ProtocolOutput::ToApplication(bytes.to_vec())]) + } + + fn process_window_update( + &mut self, + frame: &Frame, + ) -> Result, DriverProtocolError> { + self.require_attached()?; + Self::require_endpoint(frame)?; + let credit = usize::try_from(frame.fields.require_u32(data_field::CREDIT)?) + .map_err(|_| DriverProtocolError::Credit)?; + if credit == 0 { + return Err(DriverProtocolError::Credit); + } + self.daemon_receive_credit = self + .daemon_receive_credit + .saturating_add(credit) + .min(self.daemon_receive_limit); + Ok(Vec::new()) + } + + fn process_modem_status( + &self, + frame: &Frame, + ) -> Result, DriverProtocolError> { + self.require_attached()?; + Self::require_endpoint(frame)?; + Ok(vec![ProtocolOutput::ModemStatus( + frame.fields.require_u32(control_field::MASK)?, + )]) + } + + fn process_health(&self, frame: &Frame) -> Result, DriverProtocolError> { + if frame.endpoint_id != 0 && frame.endpoint_id != ENDPOINT_ID { + return Err(DriverProtocolError::WrongEndpoint); + } + let mut response = response_frame(MessageKind::HealthAck, frame, true); + response + .fields + .insert_u32(control_field::MASK, u32::from(self.attached))?; + response.fields.insert_u64( + control_field::SEQUENCE_OR_OPERATION_ID, + self.application_session, + )?; + response.fields.insert_string(control_field::DETAIL, "ok")?; + Ok(vec![encode_frame(&response, self.negotiated_frame_limit)?]) + } + + fn error_response( + &self, + frame: &Frame, + code: u32, + detail: &str, + ) -> Result, DriverProtocolError> { + let mut response = response_frame(MessageKind::Error, frame, true); + response + .fields + .insert_u32(control_field::KIND_OR_ERROR_CODE, code)?; + response + .fields + .insert_string(control_field::DETAIL, detail)?; + Ok(vec![encode_frame(&response, self.negotiated_frame_limit)?]) + } + + fn application_lifecycle( + &self, + kind: MessageKind, + ) -> Result { + let mut frame = Frame::new(kind, ENDPOINT_ID, 0); + frame.fields.insert_u64( + control_field::SEQUENCE_OR_OPERATION_ID, + self.application_session, + )?; + encode_frame(&frame, self.negotiated_frame_limit) + } + + const fn require_endpoint(frame: &Frame) -> Result<(), DriverProtocolError> { + if frame.endpoint_id == ENDPOINT_ID { + Ok(()) + } else { + Err(DriverProtocolError::WrongEndpoint) + } + } + + fn require_attached(&self) -> Result<(), DriverProtocolError> { + if self.phase != ProtocolPhase::Ready { + Err(DriverProtocolError::NotReady) + } else if !self.attached { + Err(DriverProtocolError::NotAttached) + } else { + Ok(()) + } + } +} + +impl Default for DriverProtocol { + fn default() -> Self { + Self::new() + } +} + +fn response_frame(kind: MessageKind, request: &Frame, final_response: bool) -> Frame { + let mut response = Frame::new(kind, request.endpoint_id, request.request_id); + response.flags = if final_response { + FrameFlags::RESPONSE | FrameFlags::FINAL + } else { + FrameFlags::RESPONSE + }; + response +} + +fn encode_frame(frame: &Frame, frame_limit: usize) -> Result { + let bytes = frame.encode().map_err(|_| DriverProtocolError::Encoding)?; + if bytes.len() > frame_limit { + return Err(DriverProtocolError::FrameLimit); + } + Ok(ProtocolOutput::ToDaemon(bytes)) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + fn hello(request_id: u64) -> Frame { + let mut frame = Frame::new(MessageKind::Hello, 0, request_id); + frame + .fields + .insert_u16(hello_field::MIN_MAJOR, 1) + .expect("min major"); + frame + .fields + .insert_u16(hello_field::MIN_MINOR, 0) + .expect("min minor"); + frame + .fields + .insert_u16(hello_field::MAX_MAJOR, 1) + .expect("max major"); + frame + .fields + .insert_u16(hello_field::MAX_MINOR, 0) + .expect("max minor"); + frame + .fields + .insert_u32(hello_field::MAX_FRAME_BYTES, 65_536) + .expect("frame limit"); + frame + .fields + .insert_u32(hello_field::RECEIVE_WINDOW_BYTES, 65_536) + .expect("window"); + frame + .fields + .insert_u64(hello_field::FEATURES, REQUIRED_FEATURES) + .expect("features"); + frame + .fields + .insert_string(hello_field::IDENTITY, "test-daemon") + .expect("identity"); + frame + } + + fn attach(request_id: u64) -> Frame { + let mut frame = Frame::new(MessageKind::Attach, ENDPOINT_ID, request_id); + frame + .fields + .insert_u64(session_field::SESSION_ID, 0) + .expect("session"); + frame + .fields + .insert_u32(session_field::RECEIVE_CREDIT, 65_536) + .expect("credit"); + frame + } + + fn ingest_frame(protocol: &mut DriverProtocol, frame: &Frame) -> Vec { + protocol + .ingest_daemon(&frame.encode().expect("encode")) + .expect("ingest") + } + + fn output_frame(output: &ProtocolOutput) -> Frame { + let ProtocolOutput::ToDaemon(bytes) = output else { + panic!("expected daemon output"); + }; + Frame::decode(bytes).expect("decode") + } + + #[test] + fn negotiates_discovers_and_attaches() { + let mut protocol = DriverProtocol::new(); + let hello_output = ingest_frame(&mut protocol, &hello(11)); + let hello_ack = output_frame(&hello_output[0]); + assert_eq!(hello_ack.kind, MessageKind::HelloAck); + assert_eq!(hello_ack.request_id, 11); + assert!(hello_ack.flags.contains(FrameFlags::FINAL)); + + let discover = Frame::new(MessageKind::Discover, 0, 12); + let discovery = ingest_frame(&mut protocol, &discover); + assert_eq!(output_frame(&discovery[0]).kind, MessageKind::Endpoint); + assert_eq!( + output_frame(&discovery[1]).kind, + MessageKind::DiscoverComplete + ); + + protocol.application_opened(7).expect("open"); + let attached = ingest_frame(&mut protocol, &attach(13)); + assert_eq!(output_frame(&attached[0]).kind, MessageKind::AttachAck); + assert_eq!( + output_frame(&attached[1]).kind, + MessageKind::ApplicationOpen + ); + } + + #[test] + fn stream_decoder_handles_split_hello() { + let mut protocol = DriverProtocol::new(); + let encoded = hello(1).encode().expect("encode"); + assert!( + protocol + .ingest_daemon(&encoded[..17]) + .expect("part") + .is_empty() + ); + let output = protocol.ingest_daemon(&encoded[17..]).expect("rest"); + assert_eq!(output_frame(&output[0]).kind, MessageKind::HelloAck); + } + + #[test] + fn moves_data_in_both_directions_after_attach() { + let mut protocol = DriverProtocol::new(); + ingest_frame(&mut protocol, &hello(1)); + protocol.application_opened(4).expect("open"); + ingest_frame(&mut protocol, &attach(2)); + + let to_daemon = protocol.application_data(b"FA;").expect("application data"); + let data = output_frame(&to_daemon); + assert_eq!(data.kind, MessageKind::Data); + assert_eq!(data.fields.get(data_field::BYTES), Some(b"FA;".as_slice())); + + let mut from_daemon = Frame::new(MessageKind::Data, ENDPOINT_ID, 0); + from_daemon + .fields + .insert_u64(data_field::SEQUENCE, 1) + .expect("sequence"); + from_daemon + .fields + .insert(data_field::BYTES, b"FA00007100000;") + .expect("bytes"); + assert_eq!( + ingest_frame(&mut protocol, &from_daemon), + vec![ProtocolOutput::ToApplication(b"FA00007100000;".to_vec())] + ); + let update = protocol + .application_bytes_released(15) + .expect("release") + .expect("update"); + assert_eq!(output_frame(&update).kind, MessageKind::WindowUpdate); + } + + #[test] + fn rejects_data_before_attach_and_duplicate_sequence() { + let mut protocol = DriverProtocol::new(); + ingest_frame(&mut protocol, &hello(1)); + protocol.application_opened(1).expect("open"); + assert_eq!( + protocol.application_data(b"x"), + Err(DriverProtocolError::NotAttached) + ); + ingest_frame(&mut protocol, &attach(2)); + let mut data = Frame::new(MessageKind::Data, ENDPOINT_ID, 0); + data.fields + .insert_u64(data_field::SEQUENCE, 1) + .expect("sequence"); + data.fields.insert(data_field::BYTES, b"x").expect("bytes"); + ingest_frame(&mut protocol, &data); + assert_eq!( + protocol.ingest_daemon(&data.encode().expect("encode")), + Err(DriverProtocolError::Sequence) + ); + } +} diff --git a/drivers/cathub-virtual-serial-umdf/src/serial.rs b/drivers/cathub-virtual-serial-umdf/src/serial.rs index bdb86c0..4a5bed3 100644 --- a/drivers/cathub-virtual-serial-umdf/src/serial.rs +++ b/drivers/cathub-virtual-serial-umdf/src/serial.rs @@ -427,6 +427,13 @@ impl SerialState { self.modem_output } + /// Return DTR/RTS plus the private-protocol break bit. + #[must_use] + pub const fn modem_control_mask(&self) -> u32 { + const BREAK_OUTPUT: u32 = 0x0000_0004; + self.modem_output | if self.break_active { BREAK_OUTPUT } else { 0 } + } + pub const fn set_modem_output(&mut self, value: u32) { self.modem_output = value & modem::OUTPUT; } From 1cd2e97220e2f8f9c84bc35f95a36511ebed2035 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 19:53:12 -0700 Subject: [PATCH 07/39] Connect CatHub to managed UMDF serial endpoints --- Cargo.lock | 2 + crates/cathub-virtual-serial/src/daemon.rs | 689 ++++++++++++++++++++ crates/cathub-virtual-serial/src/lib.rs | 1 + crates/cathub/Cargo.toml | 7 + crates/cathub/src/config.rs | 116 +++- crates/cathub/src/lib.rs | 126 +++- crates/cathub/src/managed_virtual_serial.rs | 559 ++++++++++++++++ crates/cathub/src/winkeyer/endpoint.rs | 13 + crates/cathub/src/winkeyer/mod.rs | 4 +- 9 files changed, 1474 insertions(+), 43 deletions(-) create mode 100644 crates/cathub-virtual-serial/src/daemon.rs create mode 100644 crates/cathub/src/managed_virtual_serial.rs diff --git a/Cargo.lock b/Cargo.lock index 70a66b1..f938364 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,6 +184,7 @@ dependencies = [ "async-trait", "bytes", "cathub-protocol", + "cathub-virtual-serial", "clap", "serde", "serde_json", @@ -197,6 +198,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "windows-sys 0.59.0", ] [[package]] diff --git a/crates/cathub-virtual-serial/src/daemon.rs b/crates/cathub-virtual-serial/src/daemon.rs new file mode 100644 index 0000000..1b79b9b --- /dev/null +++ b/crates/cathub-virtual-serial/src/daemon.rs @@ -0,0 +1,689 @@ +//! Safe daemon-side state machine for the private virtual-serial protocol. + +use crate::protocol::{ + control_field, data_field, endpoint_field, feature, hello_field, serial_field, session_field, + Frame, FrameDecoder, FrameFlags, MessageKind, ProtocolError, ABSOLUTE_MAX_FRAME_LEN, + CURRENT_VERSION, +}; + +/// Default bounded receive window advertised by the daemon. +pub const DEFAULT_RECEIVE_WINDOW: usize = 64 * 1024; + +/// Version 1.0 capabilities required by the managed transport. +pub const REQUIRED_FEATURES: u64 = feature::APPLICATION_LIFECYCLE + | feature::SERIAL_CONFIG + | feature::MODEM_LINES + | feature::CANCELLATION + | feature::PURGE + | feature::HEALTH + | feature::RECEIVE_CREDIT; + +/// One driver-reported endpoint. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EndpointDescriptor { + /// Driver-scoped endpoint identifier. + pub endpoint_id: u64, + /// Endpoint kind: 1 is CAT and 2 is WinKeyer. + pub kind: u16, + /// Stable configuration identifier. + pub stable_id: String, + /// Operator-facing name. + pub display_name: String, + /// Whether the driver currently permits attachment. + pub enabled: bool, +} + +/// Serial configuration reported by the application COM handle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SerialConfiguration { + /// Baud rate selected by the application. + pub baud: u32, + /// Number of data bits. + pub data_bits: u16, + /// Windows parity enum. + pub parity: u16, + /// Windows stop-bit enum. + pub stop_bits: u16, + /// Protocol flow-control enum. + pub flow_control: u16, + /// Read timeout in milliseconds. + pub read_timeout_ms: u32, + /// Write timeout in milliseconds. + pub write_timeout_ms: u32, +} + +/// Event produced from driver traffic. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DaemonEvent { + /// Version negotiation completed. + Negotiated, + /// One endpoint was discovered. + Endpoint(EndpointDescriptor), + /// Endpoint enumeration completed. + DiscoveryComplete, + /// Attachment completed with the active application session. + Attached { + /// Attached driver endpoint. + endpoint_id: u64, + /// Current application-open sequence, or zero while closed. + session_id: u64, + }, + /// Application bytes arrived from the public COM handle. + Data(Vec), + /// The application opened its COM handle. + ApplicationOpen(u64), + /// The application closed its COM handle. + ApplicationClose(u64), + /// The application changed its serial configuration. + SerialConfiguration(SerialConfiguration), + /// The application changed DTR, RTS, or break. + ModemControl(u32), + /// The application purged one or more queues. + Purge(u32), + /// The application canceled an operation. + Cancel { + /// Driver-assigned operation identifier. + operation_id: u64, + /// Operation kind being canceled. + kind: u32, + }, + /// A health request completed. + Health { + /// Whether the driver reports an active attachment. + attached: bool, + /// Current application-open sequence. + session_id: u64, + /// Redacted health detail. + detail: String, + }, + /// The driver detached the endpoint. + Detached, + /// The driver rejected a request. + Error { + /// Stable protocol error code. + code: u32, + /// Redacted diagnostic detail. + detail: String, + }, +} + +/// Daemon protocol failure. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum DaemonProtocolError { + /// Shared frame decoder rejected input. + #[error("invalid private transport frame: {0}")] + Protocol(#[from] ProtocolError), + /// Driver selected an incompatible contract. + #[error("driver selected an incompatible protocol")] + Version, + /// Driver omitted a required capability. + #[error("driver omitted a required feature")] + Features, + /// A frame exceeds the negotiated limit. + #[error("frame exceeds the negotiated limit")] + FrameLimit, + /// A data operation was attempted before attachment. + #[error("endpoint is not attached")] + NotAttached, + /// A data sequence was repeated or moved backward. + #[error("invalid data sequence")] + Sequence, + /// Peer exceeded its receive credit. + #[error("receive credit exhausted")] + Credit, + /// A frame addressed a different endpoint. + #[error("frame addressed a different endpoint")] + WrongEndpoint, +} + +/// State machine shared by the Windows transport adapter and its tests. +#[derive(Debug)] +pub struct DaemonProtocol { + decoder: FrameDecoder, + negotiated: bool, + attached_endpoint: Option, + frame_limit: usize, + send_limit: usize, + send_credit: usize, + receive_credit: usize, + outbound_sequence: u64, + inbound_sequence: u64, +} + +impl DaemonProtocol { + /// Create an unnegotiated daemon protocol instance. + #[must_use] + pub fn new() -> Self { + Self { + decoder: FrameDecoder::default(), + negotiated: false, + attached_endpoint: None, + frame_limit: ABSOLUTE_MAX_FRAME_LEN, + send_limit: 0, + send_credit: 0, + receive_credit: DEFAULT_RECEIVE_WINDOW, + outbound_sequence: 0, + inbound_sequence: 0, + } + } + + /// Build the initial version and capability offer. + /// + /// # Errors + /// + /// Returns an error if the frame cannot be represented by protocol 1.0. + pub fn hello(request_id: u64) -> Result, DaemonProtocolError> { + let mut frame = Frame::new(MessageKind::Hello, 0, request_id); + frame + .fields + .insert_u16(hello_field::MIN_MAJOR, CURRENT_VERSION.major)?; + frame + .fields + .insert_u16(hello_field::MIN_MINOR, CURRENT_VERSION.minor)?; + frame + .fields + .insert_u16(hello_field::MAX_MAJOR, CURRENT_VERSION.major)?; + frame + .fields + .insert_u16(hello_field::MAX_MINOR, CURRENT_VERSION.minor)?; + frame.fields.insert_u32( + hello_field::MAX_FRAME_BYTES, + u32::try_from(ABSOLUTE_MAX_FRAME_LEN).map_err(|_| DaemonProtocolError::FrameLimit)?, + )?; + frame.fields.insert_u32( + hello_field::RECEIVE_WINDOW_BYTES, + u32::try_from(DEFAULT_RECEIVE_WINDOW).map_err(|_| DaemonProtocolError::Credit)?, + )?; + frame + .fields + .insert_u64(hello_field::FEATURES, REQUIRED_FEATURES)?; + frame + .fields + .insert_string(hello_field::IDENTITY, "cathub-daemon")?; + Ok(frame.encode()?) + } + + /// Request the driver's endpoint inventory. + /// + /// # Errors + /// + /// Returns an error before negotiation or if encoding fails. + pub fn discover(&self, request_id: u64) -> Result, DaemonProtocolError> { + self.require_negotiated()?; + self.encode(&Frame::new(MessageKind::Discover, 0, request_id)) + } + + /// Attach to one discovered endpoint. + /// + /// # Errors + /// + /// Returns an error before negotiation or if encoding fails. + pub fn attach( + &self, + endpoint_id: u64, + request_id: u64, + ) -> Result, DaemonProtocolError> { + self.require_negotiated()?; + let mut frame = Frame::new(MessageKind::Attach, endpoint_id, request_id); + frame.fields.insert_u64(session_field::SESSION_ID, 0)?; + frame.fields.insert_u32( + session_field::RECEIVE_CREDIT, + u32::try_from(DEFAULT_RECEIVE_WINDOW).map_err(|_| DaemonProtocolError::Credit)?, + )?; + self.encode(&frame) + } + + /// Encode bytes produced by the CatHub endpoint session. + /// + /// # Errors + /// + /// Returns an error before attachment, when credit is exhausted, or if encoding fails. + pub fn data(&mut self, bytes: &[u8]) -> Result, DaemonProtocolError> { + let endpoint_id = self + .attached_endpoint + .ok_or(DaemonProtocolError::NotAttached)?; + if bytes.len() > self.send_credit { + return Err(DaemonProtocolError::Credit); + } + self.outbound_sequence = self.outbound_sequence.wrapping_add(1).max(1); + let mut frame = Frame::new(MessageKind::Data, endpoint_id, 0); + frame + .fields + .insert_u64(data_field::SEQUENCE, self.outbound_sequence)?; + frame.fields.insert(data_field::BYTES, bytes)?; + let encoded = self.encode(&frame)?; + self.send_credit -= bytes.len(); + Ok(encoded) + } + + /// Restore driver send credit after application bytes enter the endpoint session. + /// + /// # Errors + /// + /// Returns an error if the credit value or frame cannot be encoded. + pub fn release_received( + &mut self, + count: usize, + ) -> Result>, DaemonProtocolError> { + let Some(endpoint_id) = self.attached_endpoint else { + return Ok(None); + }; + if count == 0 { + return Ok(None); + } + self.receive_credit = self + .receive_credit + .saturating_add(count) + .min(DEFAULT_RECEIVE_WINDOW); + let mut frame = Frame::new(MessageKind::WindowUpdate, endpoint_id, 0); + frame.fields.insert_u32( + data_field::CREDIT, + u32::try_from(count).map_err(|_| DaemonProtocolError::Credit)?, + )?; + self.encode(&frame).map(Some) + } + + /// Build a transport health request. + /// + /// # Errors + /// + /// Returns an error before negotiation or if encoding fails. + pub fn health(&self, request_id: u64) -> Result, DaemonProtocolError> { + self.require_negotiated()?; + self.encode(&Frame::new(MessageKind::Health, 0, request_id)) + } + + /// Build a graceful detach frame. + /// + /// # Errors + /// + /// Returns an error if the detach frame cannot be encoded. + pub fn detach(&self, reason: u32) -> Result>, DaemonProtocolError> { + let Some(endpoint_id) = self.attached_endpoint else { + return Ok(None); + }; + let mut frame = Frame::new(MessageKind::Detach, endpoint_id, 0); + frame + .fields + .insert_u32(session_field::OPTIONS_OR_REASON, reason)?; + self.encode(&frame).map(Some) + } + + /// Consume any private stream fragment and return decoded events. + /// + /// # Errors + /// + /// Returns an error for malformed, incompatible, oversized, out-of-sequence, or + /// credit-violating driver traffic. + pub fn ingest(&mut self, bytes: &[u8]) -> Result, DaemonProtocolError> { + if self.decoder.buffered_len().saturating_add(bytes.len()) > ABSOLUTE_MAX_FRAME_LEN { + return Err(DaemonProtocolError::FrameLimit); + } + self.decoder.push(bytes); + let mut events = Vec::new(); + while let Some(frame) = self.decoder.next_frame()? { + if self.negotiated && frame.encode()?.len() > self.frame_limit { + return Err(DaemonProtocolError::FrameLimit); + } + if self.negotiated && frame.version != CURRENT_VERSION { + return Err(DaemonProtocolError::Version); + } + events.extend(self.process_frame(&frame)?); + } + Ok(events) + } + + fn process_frame(&mut self, frame: &Frame) -> Result, DaemonProtocolError> { + match frame.kind { + MessageKind::HelloAck => self.process_hello_ack(frame), + MessageKind::Endpoint => Ok(vec![DaemonEvent::Endpoint(EndpointDescriptor { + endpoint_id: frame.endpoint_id, + kind: frame.fields.require_u16(endpoint_field::KIND)?, + stable_id: frame + .fields + .require_string(endpoint_field::STABLE_ID)? + .to_owned(), + display_name: frame + .fields + .require_string(endpoint_field::DISPLAY_NAME)? + .to_owned(), + enabled: frame.fields.require_u16(endpoint_field::ENABLED)? != 0, + })]), + MessageKind::DiscoverComplete => Ok(vec![DaemonEvent::DiscoveryComplete]), + MessageKind::AttachAck => self.process_attach_ack(frame), + MessageKind::Data => self.process_data(frame), + MessageKind::WindowUpdate => self.process_window_update(frame), + MessageKind::ApplicationOpen => Ok(vec![DaemonEvent::ApplicationOpen( + frame + .fields + .require_u64(control_field::SEQUENCE_OR_OPERATION_ID)?, + )]), + MessageKind::ApplicationClose => Ok(vec![DaemonEvent::ApplicationClose( + frame + .fields + .require_u64(control_field::SEQUENCE_OR_OPERATION_ID)?, + )]), + MessageKind::SerialConfig => Ok(vec![DaemonEvent::SerialConfiguration( + SerialConfiguration { + baud: frame.fields.require_u32(serial_field::BAUD)?, + data_bits: frame.fields.require_u16(serial_field::DATA_BITS)?, + parity: frame.fields.require_u16(serial_field::PARITY)?, + stop_bits: frame.fields.require_u16(serial_field::STOP_BITS)?, + flow_control: frame.fields.require_u16(serial_field::FLOW_CONTROL)?, + read_timeout_ms: frame.fields.require_u32(serial_field::READ_TIMEOUT_MS)?, + write_timeout_ms: frame.fields.require_u32(serial_field::WRITE_TIMEOUT_MS)?, + }, + )]), + MessageKind::ModemControl => Ok(vec![DaemonEvent::ModemControl( + frame.fields.require_u32(control_field::MASK)?, + )]), + MessageKind::Purge => Ok(vec![DaemonEvent::Purge( + frame.fields.require_u32(control_field::MASK)?, + )]), + MessageKind::Cancel => Ok(vec![DaemonEvent::Cancel { + operation_id: frame + .fields + .require_u64(control_field::SEQUENCE_OR_OPERATION_ID)?, + kind: frame + .fields + .require_u32(control_field::KIND_OR_ERROR_CODE)?, + }]), + MessageKind::HealthAck => Ok(vec![DaemonEvent::Health { + attached: frame.fields.require_u32(control_field::MASK)? != 0, + session_id: frame + .fields + .require_u64(control_field::SEQUENCE_OR_OPERATION_ID)?, + detail: frame + .fields + .require_string(control_field::DETAIL)? + .to_owned(), + }]), + MessageKind::Detach => { + self.attached_endpoint = None; + Ok(vec![DaemonEvent::Detached]) + } + MessageKind::Error => Ok(vec![DaemonEvent::Error { + code: frame + .fields + .require_u32(control_field::KIND_OR_ERROR_CODE)?, + detail: frame + .fields + .require_string(control_field::DETAIL)? + .to_owned(), + }]), + _ => Ok(Vec::new()), + } + } + + fn process_hello_ack( + &mut self, + frame: &Frame, + ) -> Result, DaemonProtocolError> { + if !frame.flags.contains(FrameFlags::RESPONSE) + || frame.fields.require_u16(hello_field::MIN_MAJOR)? != CURRENT_VERSION.major + || frame.fields.require_u16(hello_field::MIN_MINOR)? != CURRENT_VERSION.minor + || frame.fields.require_u16(hello_field::MAX_MAJOR)? != CURRENT_VERSION.major + || frame.fields.require_u16(hello_field::MAX_MINOR)? != CURRENT_VERSION.minor + { + return Err(DaemonProtocolError::Version); + } + let features = frame.fields.require_u64(hello_field::FEATURES)?; + if features & REQUIRED_FEATURES != REQUIRED_FEATURES { + return Err(DaemonProtocolError::Features); + } + let frame_limit = usize::try_from(frame.fields.require_u32(hello_field::MAX_FRAME_BYTES)?) + .map_err(|_| DaemonProtocolError::FrameLimit)?; + if frame_limit < 64 { + return Err(DaemonProtocolError::FrameLimit); + } + let receive_window = usize::try_from( + frame + .fields + .require_u32(hello_field::RECEIVE_WINDOW_BYTES)?, + ) + .map_err(|_| DaemonProtocolError::Credit)?; + if receive_window == 0 { + return Err(DaemonProtocolError::Credit); + } + self.negotiated = true; + self.frame_limit = frame_limit.min(ABSOLUTE_MAX_FRAME_LEN); + self.send_limit = receive_window; + self.send_credit = receive_window; + Ok(vec![DaemonEvent::Negotiated]) + } + + fn process_attach_ack( + &mut self, + frame: &Frame, + ) -> Result, DaemonProtocolError> { + let receive_window = + usize::try_from(frame.fields.require_u32(session_field::RECEIVE_CREDIT)?) + .map_err(|_| DaemonProtocolError::Credit)?; + if receive_window == 0 { + return Err(DaemonProtocolError::Credit); + } + self.attached_endpoint = Some(frame.endpoint_id); + self.send_limit = receive_window; + self.send_credit = receive_window; + self.receive_credit = DEFAULT_RECEIVE_WINDOW; + self.outbound_sequence = 0; + self.inbound_sequence = 0; + Ok(vec![DaemonEvent::Attached { + endpoint_id: frame.endpoint_id, + session_id: frame.fields.require_u64(session_field::SESSION_ID)?, + }]) + } + + fn process_data(&mut self, frame: &Frame) -> Result, DaemonProtocolError> { + self.require_endpoint(frame)?; + let sequence = frame.fields.require_u64(data_field::SEQUENCE)?; + if sequence <= self.inbound_sequence { + return Err(DaemonProtocolError::Sequence); + } + let bytes = frame + .fields + .get(data_field::BYTES) + .ok_or(ProtocolError::MissingField(data_field::BYTES))?; + if bytes.len() > self.receive_credit { + return Err(DaemonProtocolError::Credit); + } + self.inbound_sequence = sequence; + self.receive_credit -= bytes.len(); + Ok(vec![DaemonEvent::Data(bytes.to_vec())]) + } + + fn process_window_update( + &mut self, + frame: &Frame, + ) -> Result, DaemonProtocolError> { + self.require_endpoint(frame)?; + let credit = usize::try_from(frame.fields.require_u32(data_field::CREDIT)?) + .map_err(|_| DaemonProtocolError::Credit)?; + if credit == 0 { + return Err(DaemonProtocolError::Credit); + } + self.send_credit = self.send_credit.saturating_add(credit).min(self.send_limit); + Ok(Vec::new()) + } + + fn require_negotiated(&self) -> Result<(), DaemonProtocolError> { + if self.negotiated { + Ok(()) + } else { + Err(DaemonProtocolError::Version) + } + } + + fn require_endpoint(&self, frame: &Frame) -> Result<(), DaemonProtocolError> { + if self.attached_endpoint == Some(frame.endpoint_id) { + Ok(()) + } else { + Err(DaemonProtocolError::WrongEndpoint) + } + } + + fn encode(&self, frame: &Frame) -> Result, DaemonProtocolError> { + let encoded = frame.encode()?; + if encoded.len() > self.frame_limit { + Err(DaemonProtocolError::FrameLimit) + } else { + Ok(encoded) + } + } +} + +impl Default for DaemonProtocol { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + fn hello_ack() -> Frame { + let mut frame = Frame::new(MessageKind::HelloAck, 0, 1); + frame.flags = FrameFlags::RESPONSE | FrameFlags::FINAL; + frame + .fields + .insert_u16(hello_field::MIN_MAJOR, 1) + .expect("min major"); + frame + .fields + .insert_u16(hello_field::MIN_MINOR, 0) + .expect("min minor"); + frame + .fields + .insert_u16(hello_field::MAX_MAJOR, 1) + .expect("max major"); + frame + .fields + .insert_u16(hello_field::MAX_MINOR, 0) + .expect("max minor"); + frame + .fields + .insert_u32(hello_field::MAX_FRAME_BYTES, 65_536) + .expect("frame"); + frame + .fields + .insert_u32(hello_field::RECEIVE_WINDOW_BYTES, 65_536) + .expect("window"); + frame + .fields + .insert_u64(hello_field::FEATURES, REQUIRED_FEATURES) + .expect("features"); + frame + .fields + .insert_string(hello_field::IDENTITY, "driver") + .expect("identity"); + frame + } + + fn attached_protocol() -> DaemonProtocol { + let mut protocol = DaemonProtocol::new(); + protocol + .ingest(&hello_ack().encode().expect("encode")) + .expect("hello ack"); + let mut ack = Frame::new(MessageKind::AttachAck, 7, 2); + ack.flags = FrameFlags::RESPONSE | FrameFlags::FINAL; + ack.fields + .insert_u64(session_field::SESSION_ID, 12) + .expect("session"); + ack.fields + .insert_u32(session_field::RECEIVE_CREDIT, 65_536) + .expect("credit"); + protocol + .ingest(&ack.encode().expect("encode")) + .expect("attach ack"); + protocol + } + + #[test] + fn hello_offer_contains_required_contract() { + let frame = Frame::decode(&DaemonProtocol::hello(9).expect("hello")).expect("decode"); + assert_eq!(frame.kind, MessageKind::Hello); + assert_eq!(frame.request_id, 9); + assert_eq!( + frame.fields.require_u64(hello_field::FEATURES), + Ok(REQUIRED_FEATURES) + ); + } + + #[test] + fn decodes_split_negotiation_and_discovery() { + let mut protocol = DaemonProtocol::new(); + let bytes = hello_ack().encode().expect("encode"); + assert!(protocol.ingest(&bytes[..13]).expect("part").is_empty()); + assert_eq!( + protocol.ingest(&bytes[13..]).expect("rest"), + vec![DaemonEvent::Negotiated] + ); + assert!(protocol.discover(2).is_ok()); + + let mut endpoint = Frame::new(MessageKind::Endpoint, 7, 2); + endpoint + .fields + .insert_u16(endpoint_field::KIND, 1) + .expect("kind"); + endpoint + .fields + .insert_string(endpoint_field::STABLE_ID, "n1mm") + .expect("id"); + endpoint + .fields + .insert_string(endpoint_field::DISPLAY_NAME, "N1MM") + .expect("name"); + endpoint + .fields + .insert_u16(endpoint_field::ENABLED, 1) + .expect("enabled"); + let events = protocol + .ingest(&endpoint.encode().expect("encode")) + .expect("endpoint"); + assert_eq!( + events, + vec![DaemonEvent::Endpoint(EndpointDescriptor { + endpoint_id: 7, + kind: 1, + stable_id: "n1mm".to_owned(), + display_name: "N1MM".to_owned(), + enabled: true, + })] + ); + } + + #[test] + fn attached_data_uses_sequences_and_credit() { + let mut protocol = attached_protocol(); + let outbound = Frame::decode(&protocol.data(b"reply").expect("data")).expect("decode"); + assert_eq!(outbound.endpoint_id, 7); + assert_eq!(outbound.fields.require_u64(data_field::SEQUENCE), Ok(1)); + + let mut inbound = Frame::new(MessageKind::Data, 7, 0); + inbound + .fields + .insert_u64(data_field::SEQUENCE, 1) + .expect("sequence"); + inbound + .fields + .insert(data_field::BYTES, b"request") + .expect("bytes"); + assert_eq!( + protocol + .ingest(&inbound.encode().expect("encode")) + .expect("ingest"), + vec![DaemonEvent::Data(b"request".to_vec())] + ); + let update = Frame::decode( + &protocol + .release_received(7) + .expect("release") + .expect("update"), + ) + .expect("decode"); + assert_eq!(update.kind, MessageKind::WindowUpdate); + assert_eq!(update.fields.require_u32(data_field::CREDIT), Ok(7)); + } +} diff --git a/crates/cathub-virtual-serial/src/lib.rs b/crates/cathub-virtual-serial/src/lib.rs index 6c11c5a..29c9ba4 100644 --- a/crates/cathub-virtual-serial/src/lib.rs +++ b/crates/cathub-virtual-serial/src/lib.rs @@ -7,4 +7,5 @@ #[cfg(feature = "conformance")] pub mod conformance; +pub mod daemon; pub mod protocol; diff --git a/crates/cathub/Cargo.toml b/crates/cathub/Cargo.toml index c1cbb9f..3dcd54f 100644 --- a/crates/cathub/Cargo.toml +++ b/crates/cathub/Cargo.toml @@ -20,6 +20,7 @@ path = "src/main.rs" async-trait = { workspace = true } bytes = { workspace = true } cathub-protocol = { path = "../cathub-protocol", version = "0.2.1" } +cathub-virtual-serial = { path = "../cathub-virtual-serial", version = "0.2.1", default-features = false } clap = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -34,6 +35,12 @@ tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = [ + "Win32_Devices_DeviceAndDriverInstallation", + "Win32_Foundation", +] } + [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/cathub/src/config.rs b/crates/cathub/src/config.rs index b364f57..5d47cf9 100644 --- a/crates/cathub/src/config.rs +++ b/crates/cathub/src/config.rs @@ -139,7 +139,11 @@ pub(crate) struct SerialEndpointConfig { /// A label for logging. pub(crate) name: String, /// The serial port this endpoint listens on (a com0com / tty path). + #[serde(default)] pub(crate) transport: String, + /// Stable endpoint identifier exposed by the CatHub UMDF driver. + #[serde(default)] + pub(crate) virtual_endpoint: Option, /// The paired endpoint opened by the client application. The hub never opens it. #[serde(default)] pub(crate) application_transport: Option, @@ -224,7 +228,11 @@ pub(crate) struct WinkeyerEndpointConfig { /// Stable endpoint name used in logs and ownership status. pub(crate) name: String, /// Hub side of the virtual serial pair. + #[serde(default)] pub(crate) transport: String, + /// Stable endpoint identifier exposed by the CatHub UMDF driver. + #[serde(default)] + pub(crate) virtual_endpoint: Option, /// Paired endpoint opened by the client application. The hub never opens it. #[serde(default)] pub(crate) application_transport: Option, @@ -365,6 +373,12 @@ impl Config { } } for endpoint in &self.serial_endpoint { + validate_endpoint_transport( + "serial endpoint", + &endpoint.name, + &endpoint.transport, + endpoint.virtual_endpoint.as_deref(), + )?; if endpoint .application_transport .as_deref() @@ -459,7 +473,8 @@ impl Config { let mut transports = std::collections::BTreeSet::new(); for endpoint in &self.winkeyer_endpoint { validate_winkeyer_endpoint(endpoint)?; - let normalized = endpoint.transport.to_ascii_uppercase(); + let normalized = + endpoint_transport_key(&endpoint.transport, endpoint.virtual_endpoint.as_deref()); if !transports.insert(normalized) { return Err(ConfigError::Invalid( "winkeyer endpoint transports must be distinct".to_string(), @@ -512,9 +527,10 @@ impl Config { for endpoint in &self.serial_endpoint { let _ = writeln!( out, - "serial_endpoint: name={} transport={} baud={} dialect={} perms={:?} single_vfo={}", + "serial_endpoint: name={} transport={} virtual_endpoint={} baud={} dialect={} perms={:?} single_vfo={}", endpoint.name, endpoint.transport, + endpoint.virtual_endpoint.as_deref().unwrap_or("(none)"), endpoint.baud, endpoint.dialect, endpoint.perms, @@ -538,7 +554,16 @@ impl Config { self.radio.port ); for endpoint in &self.serial_endpoint { - if let Some(application_transport) = endpoint.application_transport.as_deref() { + if let Some(stable_id) = endpoint.virtual_endpoint.as_deref() { + let _ = writeln!( + out, + " - {name}: managed COM endpoint {stable_id}, {dialect} dialect; use its Windows-assigned COM port.", + name = endpoint.name, + dialect = endpoint.dialect, + ); + } else if let Some(application_transport) = + endpoint.application_transport.as_deref() + { let _ = writeln!( out, " - {name}: hub={hub}, application={application}, {dialect} dialect, {baud} baud.", @@ -584,9 +609,10 @@ impl Config { for endpoint in &self.winkeyer_endpoint { let _ = writeln!( out, - "winkeyer_endpoint: name={} hub_transport={} application_transport={} baud={} primary={} perms={:?}", + "winkeyer_endpoint: name={} hub_transport={} virtual_endpoint={} application_transport={} baud={} primary={} perms={:?}", endpoint.name, endpoint.transport, + endpoint.virtual_endpoint.as_deref().unwrap_or("(none)"), endpoint.application_transport.as_deref().unwrap_or("(not recorded)"), endpoint.baud, endpoint.primary, @@ -707,12 +733,12 @@ fn backup_path(path: &std::path::Path) -> PathBuf { } fn validate_winkeyer_endpoint(endpoint: &WinkeyerEndpointConfig) -> Result<(), ConfigError> { - if endpoint.transport.trim().is_empty() { - return Err(ConfigError::Invalid(format!( - "winkeyer endpoint '{}' requires transport", - endpoint.name - ))); - } + validate_endpoint_transport( + "winkeyer endpoint", + &endpoint.name, + &endpoint.transport, + endpoint.virtual_endpoint.as_deref(), + )?; if endpoint.baud != 1_200 { return Err(ConfigError::Invalid(format!( "winkeyer endpoint '{}' baud must be 1200", @@ -766,11 +792,81 @@ fn validate_winkeyer_endpoint(endpoint: &WinkeyerEndpointConfig) -> Result<(), C Ok(()) } +fn validate_endpoint_transport( + kind: &str, + name: &str, + transport: &str, + virtual_endpoint: Option<&str>, +) -> Result<(), ConfigError> { + let has_transport = !transport.trim().is_empty(); + let has_virtual_endpoint = virtual_endpoint.is_some_and(|value| !value.trim().is_empty()); + if has_transport == has_virtual_endpoint { + return Err(ConfigError::Invalid(format!( + "{kind} '{name}' requires exactly one of transport or virtual_endpoint" + ))); + } + Ok(()) +} + +fn endpoint_transport_key(transport: &str, virtual_endpoint: Option<&str>) -> String { + virtual_endpoint.map_or_else( + || format!("serial:{}", transport.trim().to_ascii_uppercase()), + |stable_id| format!("managed:{}", stable_id.trim().to_ascii_lowercase()), + ) +} + #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)] mod tests { use super::*; + #[test] + fn parses_managed_virtual_serial_endpoint() { + let config = Config::parse( + r#" +[radio] +backend = "loopback" + +[[serial_endpoint]] +name = "managed-cat" +virtual_endpoint = "cathub-default" +dialect = "ts590" +perms = ["read", "write"] +"#, + ) + .expect("managed endpoint should parse"); + + assert_eq!( + config.serial_endpoint[0].virtual_endpoint.as_deref(), + Some("cathub-default") + ); + assert!(config.serial_endpoint[0].transport.is_empty()); + assert!(config + .describe() + .contains("managed COM endpoint cathub-default")); + } + + #[test] + fn rejects_ambiguous_virtual_serial_transport() { + let error = Config::parse( + r#" +[radio] +backend = "loopback" + +[[serial_endpoint]] +name = "ambiguous" +transport = "COM20" +virtual_endpoint = "cathub-default" +dialect = "ts590" +"#, + ) + .expect_err("two transport selectors must be rejected"); + + assert!(error + .to_string() + .contains("requires exactly one of transport or virtual_endpoint")); + } + const SAMPLE: &str = r#" [radio] backend = "ts590" diff --git a/crates/cathub/src/lib.rs b/crates/cathub/src/lib.rs index 84bc5e3..055c461 100644 --- a/crates/cathub/src/lib.rs +++ b/crates/cathub/src/lib.rs @@ -25,6 +25,7 @@ mod error; mod events; mod hamlib_net; mod logging; +mod managed_virtual_serial; mod model; mod permissions; mod ptt; @@ -69,6 +70,7 @@ use crate::serial_endpoint::{open_serial, run_endpoint_session}; use crate::state::StateHandle; use crate::winkeyer::{ bind_server as bind_winkeyer_server, open_serial_endpoint as open_winkeyer_endpoint, + run_managed_endpoint as run_managed_winkeyer_endpoint, run_serial_endpoint as run_winkeyer_endpoint, spawn_supervised as spawn_winkeyer, BrokerHandle as WinkeyerBrokerHandle, EndpointPermissions as WinkeyerEndpointPermissions, }; @@ -420,50 +422,110 @@ pub async fn run(cli: Cli) -> Result<(), CatHubError> { if let Some(keyer) = &winkeyer { for endpoint in &cfg.winkeyer_endpoint { let id = next_id.fetch_add(1, Ordering::SeqCst); - let port = open_winkeyer_endpoint(&endpoint.transport, endpoint.baud)?; let handle = keyer.clone(); let primary = endpoint.primary; let permissions = WinkeyerEndpointPermissions::from_tokens(&endpoint.perms); - tokio::spawn(run_winkeyer_endpoint( - port, - handle, + if let Some(stable_id) = endpoint.virtual_endpoint.clone() { + let name = endpoint.name.clone(); + tracing::info!(endpoint = %name, id, %stable_id, primary, "managed WinKeyer endpoint listening"); + tokio::spawn(async move { + loop { + match managed_virtual_serial::open(&stable_id, 2).await { + Ok(transport) => { + tracing::info!(endpoint = %name, id, %stable_id, "managed WinKeyer application connected"); + run_managed_winkeyer_endpoint( + transport, + handle.clone(), + id, + primary, + permissions, + ) + .await; + } + Err(error) => { + tracing::warn!(endpoint = %name, %stable_id, %error, "managed WinKeyer endpoint unavailable; retrying"); + } + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }); + } else { + let port = open_winkeyer_endpoint(&endpoint.transport, endpoint.baud)?; + tokio::spawn(run_winkeyer_endpoint( + port, + handle, + id, + primary, + permissions, + )); + tracing::info!( + endpoint = %endpoint.name, + id, + hub_port = %endpoint.transport, + primary, + "virtual WinKeyer endpoint listening; point the application at the paired port" + ); + } + } + } + + for endpoint in &cfg.serial_endpoint { + let dialect = dialect_for(&endpoint.dialect)?; + let id = next_id.fetch_add(1, Ordering::SeqCst); + if let Some(stable_id) = endpoint.virtual_endpoint.clone() { + let name = endpoint.name.clone(); + tracing::info!(endpoint = %name, id, %stable_id, "managed CAT endpoint listening"); + let permissions = endpoint.permissions(); + let single_vfo = endpoint.single_vfo; + let state = state.clone(); + let radio = radio.clone(); + let ptt = ptt.clone(); + let caps = caps.clone(); + tokio::spawn(async move { + loop { + match managed_virtual_serial::open(&stable_id, 1).await { + Ok(transport) => { + tracing::info!(endpoint = %name, id, %stable_id, "managed CAT application connected"); + let ctx = ClientSessionContext::new( + id, + permissions, + state.clone(), + radio.clone(), + ptt.clone(), + caps.clone(), + ) + .with_single_vfo(single_vfo); + run_endpoint_session(transport, dialect.clone(), ctx, b';').await; + } + Err(error) => { + tracing::warn!(endpoint = %name, %stable_id, %error, "managed CAT endpoint unavailable; retrying"); + } + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }); + } else { + let ctx = ClientSessionContext::new( id, - primary, - permissions, - )); + endpoint.permissions(), + state.clone(), + radio.clone(), + ptt.clone(), + caps.clone(), + ) + .with_single_vfo(endpoint.single_vfo); + let port = open_serial(&endpoint.name, &endpoint.transport, endpoint.baud)?; + tokio::spawn(run_endpoint_session(port, dialect, ctx, b';')); tracing::info!( endpoint = %endpoint.name, id, hub_port = %endpoint.transport, - primary, - "virtual WinKeyer endpoint listening; point the application at the paired port" + "serial endpoint listening; hub owns this port -- point the application at the paired \ + com0com port, not this one" ); } } - for endpoint in &cfg.serial_endpoint { - let dialect = dialect_for(&endpoint.dialect)?; - let id = next_id.fetch_add(1, Ordering::SeqCst); - let ctx = ClientSessionContext::new( - id, - endpoint.permissions(), - state.clone(), - radio.clone(), - ptt.clone(), - caps.clone(), - ) - .with_single_vfo(endpoint.single_vfo); - let port = open_serial(&endpoint.name, &endpoint.transport, endpoint.baud)?; - tokio::spawn(run_endpoint_session(port, dialect, ctx, b';')); - tracing::info!( - endpoint = %endpoint.name, - id, - hub_port = %endpoint.transport, - "serial endpoint listening; hub owns this port -- point the application at the paired \ - com0com port, not this one" - ); - } - let mut hamlib_endpoints = Vec::with_capacity(cfg.hamlib_net.len()); for ep in &cfg.hamlib_net { let id = next_id.fetch_add(1, Ordering::SeqCst); diff --git a/crates/cathub/src/managed_virtual_serial.rs b/crates/cathub/src/managed_virtual_serial.rs new file mode 100644 index 0000000..1859566 --- /dev/null +++ b/crates/cathub/src/managed_virtual_serial.rs @@ -0,0 +1,559 @@ +//! Bridge between CatHub endpoint sessions and the private UMDF transport. + +use std::io; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream}; + +const BRIDGE_CAPACITY: usize = 64 * 1024; +const MESSAGE_CAPACITY: usize = 128; + +/// Open and attach to one driver-managed virtual serial endpoint. +pub(crate) async fn open(stable_id: &str, expected_kind: u16) -> io::Result { + let stable_id = stable_id.to_owned(); + let worker = tokio::task::spawn_blocking(move || platform::connect(&stable_id, expected_kind)) + .await + .map_err(|error| io::Error::other(format!("managed transport worker failed: {error}")))??; + Ok(spawn_bridge(worker)) +} + +fn spawn_bridge(mut worker: platform::Worker) -> DuplexStream { + let (session, mut bridge) = tokio::io::duplex(BRIDGE_CAPACITY); + tokio::spawn(async move { + let mut buffer = vec![0_u8; 16 * 1024]; + let mut heartbeat = tokio::time::interval(Duration::from_secs(1)); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + read = bridge.read(&mut buffer) => match read { + Ok(0) => break, + Ok(count) => { + let bytes = buffer.get(..count).unwrap_or(&[]).to_vec(); + if worker.commands.send(platform::Command::Data(bytes)).await.is_err() { + break; + } + } + Err(error) => { + tracing::warn!(%error, "managed virtual serial session read failed"); + break; + } + }, + event = worker.events.recv() => match event { + Some(platform::Event::Data(bytes)) => { + let count = bytes.len(); + if bridge.write_all(&bytes).await.is_err() { + break; + } + if worker.commands.send(platform::Command::Release(count)).await.is_err() { + break; + } + } + Some(platform::Event::ApplicationOpen(sequence)) => { + tracing::debug!(sequence, "application opened managed COM endpoint"); + } + Some(platform::Event::ApplicationClose(sequence)) => { + tracing::debug!(sequence, "application closed managed COM endpoint"); + break; + } + Some(platform::Event::SerialConfiguration(config)) => { + tracing::debug!(?config, "application updated managed COM settings"); + } + Some(platform::Event::ModemControl(lines)) => { + tracing::debug!(lines, "application updated managed COM modem lines"); + } + Some(platform::Event::Closed) | None => break, + Some(platform::Event::Failed(error)) => { + tracing::warn!(%error, "managed virtual serial transport failed"); + break; + } + }, + _ = heartbeat.tick() => { + if worker.commands.send(platform::Command::Health).await.is_err() { + break; + } + } + } + } + + let _ = worker.commands.send(platform::Command::Shutdown).await; + }); + session +} + +#[cfg(not(windows))] +mod platform { + use std::io; + + use cathub_virtual_serial::daemon::SerialConfiguration; + use tokio::sync::mpsc; + + pub(super) enum Command { + Data(Vec), + Release(usize), + Health, + Shutdown, + } + + pub(super) enum Event { + Data(Vec), + ApplicationOpen(u64), + ApplicationClose(u64), + SerialConfiguration(SerialConfiguration), + ModemControl(u32), + Closed, + Failed(String), + } + + pub(super) struct Worker { + pub(super) commands: mpsc::Sender, + pub(super) events: mpsc::Receiver, + } + + pub(super) fn connect(_stable_id: &str, _expected_kind: u16) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "managed virtual serial endpoints require Windows", + )) + } +} + +#[cfg(windows)] +#[allow(unsafe_code)] +mod platform { + use std::fs::{File, OpenOptions}; + use std::io::{self, Read, Write}; + use std::mem::{offset_of, size_of}; + use std::ptr::{null, null_mut}; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + use std::sync::{Arc, Mutex}; + + use cathub_virtual_serial::daemon::{ + DaemonEvent, DaemonProtocol, EndpointDescriptor, SerialConfiguration, + }; + use tokio::sync::mpsc; + use windows_sys::core::GUID; + use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{ + SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW, + SetupDiGetDeviceInterfaceDetailW, DIGCF_DEVICEINTERFACE, DIGCF_PRESENT, HDEVINFO, + SP_DEVICE_INTERFACE_DATA, SP_DEVICE_INTERFACE_DETAIL_DATA_W, + }; + use windows_sys::Win32::Foundation::{ + GetLastError, ERROR_INSUFFICIENT_BUFFER, ERROR_NO_MORE_ITEMS, INVALID_HANDLE_VALUE, + }; + + const PRIVATE_INTERFACE: GUID = GUID { + data1: 0x0084_BDDE, + data2: 0x9F40, + data3: 0x4A6A, + data4: [0xAF, 0x84, 0x0F, 0x4E, 0x46, 0xB7, 0x09, 0x01], + }; + + pub(super) enum Command { + Data(Vec), + Release(usize), + Health, + Shutdown, + } + + pub(super) enum Event { + Data(Vec), + ApplicationOpen(u64), + ApplicationClose(u64), + SerialConfiguration(SerialConfiguration), + ModemControl(u32), + Closed, + Failed(String), + } + + pub(super) struct Worker { + pub(super) commands: mpsc::Sender, + pub(super) events: mpsc::Receiver, + } + + pub(super) fn connect(stable_id: &str, expected_kind: u16) -> io::Result { + let paths = enumerate_interfaces()?; + let mut last_error = None; + for path in paths { + match connect_path(&path, stable_id, expected_kind) { + Ok(worker) => return Ok(worker), + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "CatHub UMDF private interface was not found", + ) + })) + } + + fn connect_path(path: &str, stable_id: &str, expected_kind: u16) -> io::Result { + let mut file = OpenOptions::new().read(true).write(true).open(path)?; + let mut protocol = DaemonProtocol::new(); + + write_frame( + &mut file, + &DaemonProtocol::hello(1).map_err(protocol_error)?, + )?; + read_until(&mut file, &mut protocol, |event| { + matches!(event, DaemonEvent::Negotiated) + })?; + + write_frame(&mut file, &protocol.discover(2).map_err(protocol_error)?)?; + let discovery = read_until(&mut file, &mut protocol, |event| { + matches!(event, DaemonEvent::DiscoveryComplete) + })?; + let endpoint = discovery + .iter() + .find_map(|event| match event { + DaemonEvent::Endpoint(endpoint) if endpoint.stable_id == stable_id => { + Some(endpoint.clone()) + } + _ => None, + }) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("managed virtual endpoint `{stable_id}` was not found"), + ) + })?; + validate_endpoint(&endpoint, expected_kind)?; + + write_frame( + &mut file, + &protocol + .attach(endpoint.endpoint_id, 3) + .map_err(protocol_error)?, + )?; + read_until(&mut file, &mut protocol, |event| { + matches!(event, DaemonEvent::Attached { .. }) + })?; + + spawn_workers(file, protocol) + } + + fn validate_endpoint(endpoint: &EndpointDescriptor, expected_kind: u16) -> io::Result<()> { + if !endpoint.enabled { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "managed virtual endpoint `{}` is disabled", + endpoint.stable_id + ), + )); + } + if endpoint.kind != expected_kind { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "managed virtual endpoint `{}` has kind {}, expected {expected_kind}", + endpoint.stable_id, endpoint.kind + ), + )); + } + Ok(()) + } + + fn spawn_workers(file: File, protocol: DaemonProtocol) -> io::Result { + let reader_file = file.try_clone()?; + let protocol = Arc::new(Mutex::new(protocol)); + let stopping = Arc::new(AtomicBool::new(false)); + let request_id = Arc::new(AtomicU64::new(10)); + let (commands_tx, commands_rx) = mpsc::channel(super::MESSAGE_CAPACITY); + let (events_tx, events_rx) = mpsc::channel(super::MESSAGE_CAPACITY); + + { + let protocol = protocol.clone(); + let stopping = stopping.clone(); + let events = events_tx.clone(); + std::thread::Builder::new() + .name("cathub-umdf-reader".to_owned()) + .spawn(move || reader_loop(reader_file, protocol, stopping, events))?; + } + { + let protocol = protocol.clone(); + let stopping = stopping.clone(); + std::thread::Builder::new() + .name("cathub-umdf-writer".to_owned()) + .spawn(move || writer_loop(file, protocol, stopping, request_id, commands_rx))?; + } + + Ok(Worker { + commands: commands_tx, + events: events_rx, + }) + } + + #[allow(clippy::needless_pass_by_value)] + fn reader_loop( + mut file: File, + protocol: Arc>, + stopping: Arc, + events: mpsc::Sender, + ) { + let mut buffer = vec![0_u8; 16 * 1024]; + loop { + let count = match file.read(&mut buffer) { + Ok(0) => { + let _ = events.blocking_send(Event::Closed); + return; + } + Ok(count) => count, + Err(error) => { + let _ = events.blocking_send(Event::Failed(error.to_string())); + return; + } + }; + let decoded = match protocol.lock() { + Ok(mut protocol) => protocol.ingest(buffer.get(..count).unwrap_or(&[])), + Err(error) => { + let _ = events.blocking_send(Event::Failed(error.to_string())); + return; + } + }; + match decoded { + Ok(decoded) => { + for event in decoded { + if let Some(event) = translate_event(event) { + if events.blocking_send(event).is_err() { + return; + } + } + } + } + Err(error) => { + let _ = events.blocking_send(Event::Failed(error.to_string())); + return; + } + } + if stopping.load(Ordering::Acquire) { + let _ = events.blocking_send(Event::Closed); + return; + } + } + } + + #[allow(clippy::needless_pass_by_value)] + fn writer_loop( + mut file: File, + protocol: Arc>, + stopping: Arc, + request_id: Arc, + mut commands: mpsc::Receiver, + ) { + while let Some(command) = commands.blocking_recv() { + let result = match command { + Command::Data(bytes) => with_protocol(&protocol, |protocol| protocol.data(&bytes)) + .and_then(|frame| write_frame(&mut file, &frame)), + Command::Release(count) => { + with_protocol(&protocol, |protocol| protocol.release_received(count)) + .and_then(|frame| write_optional_frame(&mut file, frame)) + } + Command::Health => with_protocol(&protocol, |protocol| { + protocol.health(request_id.fetch_add(1, Ordering::Relaxed)) + }) + .and_then(|frame| write_frame(&mut file, &frame)), + Command::Shutdown => { + stopping.store(true, Ordering::Release); + let detach = with_protocol(&protocol, |protocol| protocol.detach(0)); + if let Ok(frame) = detach { + let _ = write_optional_frame(&mut file, frame); + } + with_protocol(&protocol, |protocol| { + protocol.health(request_id.fetch_add(1, Ordering::Relaxed)) + }) + .and_then(|frame| write_frame(&mut file, &frame)) + } + }; + if result.is_err() || stopping.load(Ordering::Acquire) { + return; + } + } + } + + fn with_protocol( + protocol: &Mutex, + operation: impl FnOnce( + &mut DaemonProtocol, + ) -> Result, + ) -> io::Result { + let mut guard = protocol + .lock() + .map_err(|error| io::Error::other(error.to_string()))?; + operation(&mut guard).map_err(protocol_error) + } + + fn translate_event(event: DaemonEvent) -> Option { + match event { + DaemonEvent::Data(bytes) => Some(Event::Data(bytes)), + DaemonEvent::ApplicationOpen(sequence) => Some(Event::ApplicationOpen(sequence)), + DaemonEvent::ApplicationClose(sequence) => Some(Event::ApplicationClose(sequence)), + DaemonEvent::SerialConfiguration(config) => Some(Event::SerialConfiguration(config)), + DaemonEvent::ModemControl(lines) => Some(Event::ModemControl(lines)), + _ => None, + } + } + + fn read_until( + file: &mut File, + protocol: &mut DaemonProtocol, + done: impl Fn(&DaemonEvent) -> bool, + ) -> io::Result> { + let mut all_events = Vec::new(); + let mut buffer = vec![0_u8; 16 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "UMDF private channel closed during handshake", + )); + } + let events = protocol + .ingest(buffer.get(..count).unwrap_or(&[])) + .map_err(protocol_error)?; + let complete = events.iter().any(&done); + all_events.extend(events); + if complete { + return Ok(all_events); + } + } + } + + fn write_frame(file: &mut File, frame: &[u8]) -> io::Result<()> { + file.write_all(frame) + } + + fn write_optional_frame(file: &mut File, frame: Option>) -> io::Result<()> { + frame.map_or(Ok(()), |frame| write_frame(file, &frame)) + } + + fn protocol_error(error: impl std::fmt::Display) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, error.to_string()) + } + + fn enumerate_interfaces() -> io::Result> { + let interface_guid = PRIVATE_INTERFACE; + let handle = unsafe { + SetupDiGetClassDevsW( + &raw const interface_guid, + null(), + null_mut(), + DIGCF_PRESENT | DIGCF_DEVICEINTERFACE, + ) + }; + if handle == INVALID_HANDLE_VALUE as HDEVINFO { + return Err(io::Error::last_os_error()); + } + let set = DeviceInfoSet(handle); + let mut paths = Vec::new(); + let mut index = 0; + loop { + let mut interface = SP_DEVICE_INTERFACE_DATA { + cbSize: u32::try_from(size_of::()).unwrap_or(u32::MAX), + InterfaceClassGuid: zero_guid(), + Flags: 0, + Reserved: 0, + }; + let found = unsafe { + SetupDiEnumDeviceInterfaces( + set.0, + null(), + &raw const interface_guid, + index, + &raw mut interface, + ) + }; + if found == 0 { + let error = unsafe { GetLastError() }; + if error == ERROR_NO_MORE_ITEMS { + break; + } + return Err(io::Error::from_raw_os_error(error.cast_signed())); + } + paths.push(interface_path(set.0, &mut interface)?); + index += 1; + } + Ok(paths) + } + + fn interface_path( + set: HDEVINFO, + interface: &mut SP_DEVICE_INTERFACE_DATA, + ) -> io::Result { + let mut required = 0; + unsafe { + SetupDiGetDeviceInterfaceDetailW( + set, + interface, + null_mut(), + 0, + &raw mut required, + null_mut(), + ); + } + let error = unsafe { GetLastError() }; + if error != ERROR_INSUFFICIENT_BUFFER { + return Err(io::Error::from_raw_os_error(error.cast_signed())); + } + + let byte_count = usize::try_from(required).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "interface path is too large") + })?; + let words = byte_count.div_ceil(size_of::()); + let mut storage = vec![0_usize; words]; + let detail = storage + .as_mut_ptr() + .cast::(); + unsafe { + (*detail).cbSize = + u32::try_from(size_of::()).unwrap_or(u32::MAX); + } + let ok = unsafe { + SetupDiGetDeviceInterfaceDetailW( + set, + interface, + detail, + required, + null_mut(), + null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + + let path = unsafe { + let offset = offset_of!(SP_DEVICE_INTERFACE_DETAIL_DATA_W, DevicePath); + let start = detail.cast::().add(offset / size_of::()); + let units = byte_count.saturating_sub(offset) / size_of::(); + std::slice::from_raw_parts(start, units) + }; + let length = path + .iter() + .position(|unit| *unit == 0) + .unwrap_or(path.len()); + String::from_utf16(path.get(..length).unwrap_or(&[])) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) + } + + const fn zero_guid() -> GUID { + GUID { + data1: 0, + data2: 0, + data3: 0, + data4: [0; 8], + } + } + + struct DeviceInfoSet(HDEVINFO); + + impl Drop for DeviceInfoSet { + fn drop(&mut self) { + unsafe { + SetupDiDestroyDeviceInfoList(self.0); + } + } + } +} diff --git a/crates/cathub/src/winkeyer/endpoint.rs b/crates/cathub/src/winkeyer/endpoint.rs index 1609b67..89ae806 100644 --- a/crates/cathub/src/winkeyer/endpoint.rs +++ b/crates/cathub/src/winkeyer/endpoint.rs @@ -66,6 +66,19 @@ pub(crate) async fn run_serial_endpoint( run_endpoint_session_inner(transport, broker, client_id, primary, permissions, true).await; } +/// Serve a managed UMDF endpoint where a zero-byte read indicates a disconnected application. +pub(crate) async fn run_managed_endpoint( + transport: T, + broker: BrokerHandle, + client_id: ClientId, + primary: bool, + permissions: EndpointPermissions, +) where + T: AsyncRead + AsyncWrite + Send + 'static, +{ + run_endpoint_session_inner(transport, broker, client_id, primary, permissions, false).await; +} + #[allow(clippy::too_many_arguments, clippy::too_many_lines)] async fn run_endpoint_session_inner( transport: T, diff --git a/crates/cathub/src/winkeyer/mod.rs b/crates/cathub/src/winkeyer/mod.rs index e3a8e55..60a06e0 100644 --- a/crates/cathub/src/winkeyer/mod.rs +++ b/crates/cathub/src/winkeyer/mod.rs @@ -11,5 +11,7 @@ mod grpc; mod protocol; pub(crate) use actor::{spawn_supervised, BrokerHandle}; -pub(crate) use endpoint::{open_serial_endpoint, run_serial_endpoint, EndpointPermissions}; +pub(crate) use endpoint::{ + open_serial_endpoint, run_managed_endpoint, run_serial_endpoint, EndpointPermissions, +}; pub(crate) use grpc::bind_server; From 63fe3769ae33e8af25c28a90e2aa62ba5f86c18d Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 20:01:11 -0700 Subject: [PATCH 08/39] Add isolated UMDF deployment and end-to-end test --- drivers/cathub-virtual-serial-umdf/README.md | 11 +- scripts/Test-UmdfEndToEnd.ps1 | 211 +++++++++++++++++++ scripts/Test-UmdfPoc.ps1 | 117 +++++++++- 3 files changed, 330 insertions(+), 9 deletions(-) create mode 100644 scripts/Test-UmdfEndToEnd.ps1 diff --git a/drivers/cathub-virtual-serial-umdf/README.md b/drivers/cathub-virtual-serial-umdf/README.md index 7e05d96..fc53354 100644 --- a/drivers/cathub-virtual-serial-umdf/README.md +++ b/drivers/cathub-virtual-serial-umdf/README.md @@ -54,16 +54,17 @@ To build and validate an unsigned package without creating a certificate: .\scripts\Test-UmdfPoc.ps1 -Action ValidatePackage ``` -On an isolated driver-development target, produce the upstream test-signed package with: +Produce a test-signed package for an isolated driver-development target with: ```powershell .\scripts\Test-UmdfPoc.ps1 -Action Package ``` -The `Package` action generates a certificate in the upstream workflow's private test store and -uses it to sign the package. It requires `makecert` and `signtool`. -Building a package does not authorize installing its certificate or driver. -Keep any test certificate off normal operator machines. +The `Package` action generates a short-lived code-signing certificate without adding it to the +host certificate store, signs the package catalog with `signtool`, deletes the private-key file, +and leaves the public `cathub_umdf_test.cer` in the package for the isolated target. Building a +package does not authorize installing its certificate or driver. Keep the public test certificate +off normal operator machines. ## Current safety boundary diff --git a/scripts/Test-UmdfEndToEnd.ps1 b/scripts/Test-UmdfEndToEnd.ps1 new file mode 100644 index 0000000..2e2a333 --- /dev/null +++ b/scripts/Test-UmdfEndToEnd.ps1 @@ -0,0 +1,211 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [switch]$IUnderstandThisInstallsATestDriver, + + [string]$DriverPackage = (Join-Path $PSScriptRoot 'driver'), + [string]$CatHubExe = (Join-Path $PSScriptRoot 'cathub.exe'), + [string]$DevConExe = (Join-Path $PSScriptRoot 'devcon.exe'), + [string]$ResultsPath = (Join-Path $PSScriptRoot 'cathub-umdf-e2e.json') +) + +$ErrorActionPreference = 'Stop' + +function Invoke-Checked { + param( + [Parameter(Mandatory)][string]$Command, + [Parameter(Mandatory)][string[]]$Arguments + ) + + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "'$Command $($Arguments -join ' ')' failed with exit code $LASTEXITCODE." + } +} + +function Find-CatHubPort { + $deadline = [DateTime]::UtcNow.AddSeconds(20) + do { + $device = Get-PnpDevice -Class Ports -PresentOnly -ErrorAction SilentlyContinue | + Where-Object InstanceId -Like 'ROOT\CATHUB_VIRTUAL_SERIAL*' | + Select-Object -First 1 + if ($device) { + $match = [regex]::Match($device.FriendlyName, '\((COM\d+)\)') + if ($match.Success) { + return $match.Groups[1].Value + } + + $enumPath = "HKLM:\SYSTEM\CurrentControlSet\Enum\$($device.InstanceId)" + foreach ($path in @($enumPath, (Join-Path $enumPath 'Device Parameters'))) { + $portName = (Get-ItemProperty -LiteralPath $path -Name PortName ` + -ErrorAction SilentlyContinue).PortName + if ($portName -match '^COM\d+$') { + return $portName + } + } + } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + + throw 'The CatHub virtual COM port did not appear within 20 seconds.' +} + +function Invoke-CatQuery { + param( + [Parameter(Mandatory)][System.IO.Ports.SerialPort]$Port, + [Parameter(Mandatory)][string]$Command + ) + + $Port.Write($Command) + return $Port.ReadTo(';') + ';' +} + +if (-not $IUnderstandThisInstallsATestDriver) { + throw 'Pass -IUnderstandThisInstallsATestDriver on an isolated test VM.' +} + +$computer = Get-CimInstance Win32_ComputerSystem +if ($computer.Manufacturer -ne 'Microsoft Corporation' -or $computer.Model -ne 'Virtual Machine') { + throw 'This test installs a private test certificate and driver and is restricted to a Hyper-V VM.' +} + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run this test from an elevated PowerShell session inside the isolated VM.' +} + +$infPath = Join-Path $DriverPackage 'cathub_virtual_serial_umdf.inf' +$certificatePath = Join-Path $DriverPackage 'cathub_umdf_test.cer' +foreach ($path in @($infPath, $certificatePath, $CatHubExe, $DevConExe)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Required test input is missing: $path" + } +} + +$workRoot = Join-Path $env:TEMP 'cathub-umdf-e2e' +$configPath = Join-Path $workRoot 'cathub.toml' +$stdoutPath = Join-Path $workRoot 'cathub.stdout.log' +$stderrPath = Join-Path $workRoot 'cathub.stderr.log' +$null = New-Item -ItemType Directory -Path $workRoot -Force + +@' +[radio] +backend = "loopback" +model = "TS-590SG" + +[[serial_endpoint]] +name = "umdf-e2e" +virtual_endpoint = "cathub-default" +dialect = "ts590" +perms = ["read", "frequency_write", "write", "ptt", "config_write"] +'@ | Set-Content -LiteralPath $configPath -Encoding utf8 + +$certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + $certificatePath +) +Invoke-Checked certutil @('-f', '-addstore', 'Root', $certificatePath) +Invoke-Checked certutil @('-f', '-addstore', 'TrustedPublisher', $certificatePath) +Invoke-Checked $DevConExe @('install', $infPath, 'root\CATHUB_VIRTUAL_SERIAL') + +$portName = Find-CatHubPort +$process = $null +$serial = $null +$results = [ordered]@{ + timestamp_utc = [DateTime]::UtcNow.ToString('o') + machine = $env:COMPUTERNAME + port = $portName + driver_certificate = $certificate.Thumbprint + cases = @() +} + +try { + $process = Start-Process -FilePath $CatHubExe ` + -ArgumentList @('--config', $configPath) ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath ` + -PassThru + Start-Sleep -Seconds 2 + if ($process.HasExited) { + throw "CatHub exited during startup with code $($process.ExitCode)." + } + + $serial = [System.IO.Ports.SerialPort]::new( + $portName, + 9600, + [System.IO.Ports.Parity]::None, + 8, + [System.IO.Ports.StopBits]::One + ) + $serial.ReadTimeout = 5000 + $serial.WriteTimeout = 5000 + $serial.DtrEnable = $true + $serial.RtsEnable = $true + $serial.Open() + $serial.DiscardInBuffer() + $serial.DiscardOutBuffer() + + $id = Invoke-CatQuery -Port $serial -Command 'ID;' + if ($id -ne 'ID021;') { + throw "Unexpected ID response '$id'." + } + $results.cases += [ordered]@{ name = 'initial_id_query'; passed = $true; response = $id } + + $serial.Write('FA00014074000;') + $frequency = Invoke-CatQuery -Port $serial -Command 'FA;' + if ($frequency -ne 'FA00014074000;') { + throw "Unexpected frequency response '$frequency'." + } + $results.cases += [ordered]@{ + name = 'bidirectional_frequency_round_trip' + passed = $true + response = $frequency + } + + $serial.Close() + $serial.Dispose() + $serial = $null + Start-Sleep -Seconds 1 + + $serial = [System.IO.Ports.SerialPort]::new($portName, 4800) + $serial.ReadTimeout = 5000 + $serial.WriteTimeout = 5000 + $serial.Open() + $reopenedId = Invoke-CatQuery -Port $serial -Command 'ID;' + if ($reopenedId -ne 'ID021;') { + throw "Unexpected ID response after reopen '$reopenedId'." + } + $results.cases += [ordered]@{ + name = 'close_reopen_reconnect' + passed = $true + response = $reopenedId + } + $results.passed = $true +} +catch { + $results.passed = $false + $results.error = $_.Exception.Message + throw +} +finally { + if ($serial) { + if ($serial.IsOpen) { + $serial.Close() + } + $serial.Dispose() + } + if ($process -and -not $process.HasExited) { + Stop-Process -Id $process.Id -Force + $process.WaitForExit() + } + $results.cathub_stdout = if (Test-Path -LiteralPath $stdoutPath) { + Get-Content -LiteralPath $stdoutPath -Raw + } else { '' } + $results.cathub_stderr = if (Test-Path -LiteralPath $stderrPath) { + Get-Content -LiteralPath $stderrPath -Raw + } else { '' } + $results | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $ResultsPath -Encoding utf8 +} + +Write-Host "CatHub UMDF end-to-end test passed on $portName." +Write-Host "Results: $ResultsPath" diff --git a/scripts/Test-UmdfPoc.ps1 b/scripts/Test-UmdfPoc.ps1 index 3dafbee..03a15a2 100644 --- a/scripts/Test-UmdfPoc.ps1 +++ b/scripts/Test-UmdfPoc.ps1 @@ -65,6 +65,26 @@ function Add-PathEntry { } } +function Find-SignTool { + $command = Get-Command signtool -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + + $kitsBin = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + if (Test-Path -LiteralPath $kitsBin) { + $candidate = Get-ChildItem -LiteralPath $kitsBin -Directory | + Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'x64\signtool.exe') } | + Sort-Object { [version]$_.Name } -Descending | + Select-Object -First 1 + if ($candidate) { + return Join-Path $candidate.FullName 'x64\signtool.exe' + } + } + + throw 'Required UMDF build command signtool is not available. Install the Windows SDK signing tools.' +} + function Initialize-WdkEnvironment { $contentRoot = Find-WdkContentRoot $versionDirectory = Get-ChildItem -LiteralPath (Join-Path $contentRoot 'Include') -Directory | @@ -100,6 +120,70 @@ function Invoke-Checked { } } +function New-EphemeralDriverCertificate { + param( + [Parameter(Mandatory)][string]$PfxPath, + [Parameter(Mandatory)][string]$CerPath, + [Parameter(Mandatory)][string]$Password + ) + + $rsa = [System.Security.Cryptography.RSA]::Create(3072) + try { + $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=CatHub UMDF Test Certificate', + $rsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + ) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new( + $false, $false, 0, $true + ) + ) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature, + $true + ) + ) + $usages = [System.Security.Cryptography.OidCollection]::new() + $null = $usages.Add([System.Security.Cryptography.Oid]::new( + '1.3.6.1.5.5.7.3.3', 'Code Signing' + )) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new( + $usages, $true + ) + ) + + $certificate = $request.CreateSelfSigned( + [DateTimeOffset]::UtcNow.AddMinutes(-5), + [DateTimeOffset]::UtcNow.AddDays(30) + ) + try { + [System.IO.File]::WriteAllBytes( + $PfxPath, + $certificate.Export( + [System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, + $Password + ) + ) + [System.IO.File]::WriteAllBytes( + $CerPath, + $certificate.Export( + [System.Security.Cryptography.X509Certificates.X509ContentType]::Cert + ) + ) + } + finally { + $certificate.Dispose() + } + } + finally { + $rsa.Dispose() + } +} + Assert-Command cargo Assert-Command clang Initialize-WdkEnvironment @@ -107,12 +191,37 @@ Initialize-WdkEnvironment Push-Location $driverRoot try { if ($Action -eq 'Package') { - foreach ($command in @( - 'cargo-make', 'inf2cat', 'infverif', 'stampinf', 'makecert', 'signtool' - )) { + foreach ($command in @('cargo-make', 'inf2cat', 'infverif', 'stampinf')) { Assert-Command $command } - Invoke-Checked cargo @('make', 'default', '--target', 'x86_64-pc-windows-msvc') + $signTool = Find-SignTool + Invoke-Checked cargo @( + 'make', 'package-unsigned', '--target', 'x86_64-pc-windows-msvc' + ) + + $packageRoot = Join-Path $driverRoot ` + 'target\x86_64-pc-windows-msvc\debug\cathub_virtual_serial_umdf_package' + $catalogPath = Join-Path $packageRoot 'cathub_virtual_serial_umdf.cat' + $certificatePath = Join-Path $packageRoot 'cathub_umdf_test.cer' + $pfxPath = Join-Path ([System.IO.Path]::GetTempPath()) ` + "cathub-umdf-$([guid]::NewGuid().ToString('N')).pfx" + $password = [guid]::NewGuid().ToString('N') + try { + New-EphemeralDriverCertificate ` + -PfxPath $pfxPath ` + -CerPath $certificatePath ` + -Password $password + Invoke-Checked $signTool @( + 'sign', '/v', '/fd', 'SHA256', '/f', $pfxPath, '/p', $password, $catalogPath + ) + } + finally { + if (Test-Path -LiteralPath $pfxPath) { + Remove-Item -LiteralPath $pfxPath -Force + } + } + Write-Host "Signed test package: $packageRoot" + Write-Host 'The public test certificate is included for the isolated target only.' } elseif ($Action -eq 'ValidatePackage') { foreach ($command in @('cargo-make', 'inf2cat', 'infverif', 'stampinf')) { From 0b05353aa292ac9dc735293d55312fa277f0d76e Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 20:04:09 -0700 Subject: [PATCH 09/39] Verify driver and daemon protocol interoperability --- .../src/private_protocol.rs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/drivers/cathub-virtual-serial-umdf/src/private_protocol.rs b/drivers/cathub-virtual-serial-umdf/src/private_protocol.rs index 888bbb4..60436fe 100644 --- a/drivers/cathub-virtual-serial-umdf/src/private_protocol.rs +++ b/drivers/cathub-virtual-serial-umdf/src/private_protocol.rs @@ -557,6 +557,7 @@ fn encode_frame(frame: &Frame, frame_limit: usize) -> Result Frame { let mut frame = Frame::new(MessageKind::Hello, 0, request_id); @@ -621,6 +622,17 @@ mod tests { Frame::decode(bytes).expect("decode") } + fn daemon_events(daemon: &mut DaemonProtocol, outputs: &[ProtocolOutput]) -> Vec { + let mut events = Vec::new(); + for output in outputs { + let ProtocolOutput::ToDaemon(bytes) = output else { + continue; + }; + events.extend(daemon.ingest(bytes).expect("daemon ingest")); + } + events + } + #[test] fn negotiates_discovers_and_attaches() { let mut protocol = DriverProtocol::new(); @@ -714,4 +726,71 @@ mod tests { Err(DriverProtocolError::Sequence) ); } + + #[test] + fn interoperates_with_the_cathub_daemon_state_machine() { + let mut driver = DriverProtocol::new(); + let mut daemon = DaemonProtocol::new(); + + let outputs = driver + .ingest_daemon(&DaemonProtocol::hello(1).expect("hello")) + .expect("driver hello"); + assert_eq!( + daemon_events(&mut daemon, &outputs), + vec![DaemonEvent::Negotiated] + ); + + let outputs = driver + .ingest_daemon(&daemon.discover(2).expect("discover")) + .expect("driver discovery"); + let events = daemon_events(&mut daemon, &outputs); + assert!(events.iter().any(|event| matches!( + event, + DaemonEvent::Endpoint(endpoint) + if endpoint.stable_id == "cathub-default" && endpoint.kind == 1 + ))); + assert!(events.contains(&DaemonEvent::DiscoveryComplete)); + + driver.application_opened(41).expect("application open"); + let outputs = driver + .ingest_daemon(&daemon.attach(ENDPOINT_ID, 3).expect("attach")) + .expect("driver attach"); + assert_eq!( + daemon_events(&mut daemon, &outputs), + vec![ + DaemonEvent::Attached { + endpoint_id: ENDPOINT_ID, + session_id: 41, + }, + DaemonEvent::ApplicationOpen(41), + ] + ); + + let to_daemon = driver.application_data(b"ID;").expect("application data"); + assert_eq!( + daemon_events(&mut daemon, &[to_daemon]), + vec![DaemonEvent::Data(b"ID;".to_vec())] + ); + let credit = daemon + .release_received(3) + .expect("release") + .expect("credit frame"); + assert!( + driver + .ingest_daemon(&credit) + .expect("driver credit") + .is_empty() + ); + + let to_application = daemon.data(b"ID021;").expect("daemon data"); + assert_eq!( + driver.ingest_daemon(&to_application).expect("driver data"), + vec![ProtocolOutput::ToApplication(b"ID021;".to_vec())] + ); + let update = driver + .application_bytes_released(6) + .expect("application release") + .expect("window update"); + assert!(daemon_events(&mut daemon, &[update]).is_empty()); + } } From 05b3cdbbbe2f1d95371f2ace0a2f7d61ecd5748b Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 20:12:00 -0700 Subject: [PATCH 10/39] Enforce serial read timeouts in the UMDF driver --- .../cathub-virtual-serial-umdf/src/interop.rs | 150 +++++++++++++++++- .../cathub-virtual-serial-umdf/src/serial.rs | 43 +++++ scripts/Test-UmdfEndToEnd.ps1 | 19 +++ 3 files changed, 208 insertions(+), 4 deletions(-) diff --git a/drivers/cathub-virtual-serial-umdf/src/interop.rs b/drivers/cathub-virtual-serial-umdf/src/interop.rs index d19f48a..46dd81a 100644 --- a/drivers/cathub-virtual-serial-umdf/src/interop.rs +++ b/drivers/cathub-virtual-serial-umdf/src/interop.rs @@ -1,6 +1,7 @@ //! Auditable Windows/WDF FFI boundary. use std::{ + collections::VecDeque, ffi::c_void, mem::size_of, panic::{AssertUnwindSafe, catch_unwind}, @@ -9,6 +10,7 @@ use std::{ Mutex, MutexGuard, atomic::{AtomicPtr, Ordering}, }, + time::{Duration, Instant}, }; use wdk::println; @@ -17,9 +19,9 @@ use wdk_sys::{ _WDF_SYNCHRONIZATION_SCOPE, _WDF_TRI_STATE, BOOLEAN, GUID, KEY_QUERY_VALUE, NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PLUGPLAY_REGKEY_DEVICE, PVOID, ULONG, ULONG_PTR, UNICODE_STRING, WDF_DRIVER_CONFIG, WDF_FILEOBJECT_CONFIG, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, - WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES, WDF_OBJECT_CONTEXT_TYPE_INFO, WDFDEVICE, - WDFDEVICE_INIT, WDFDRIVER, WDFFILEOBJECT, WDFKEY, WDFOBJECT, WDFQUEUE, WDFQUEUE__, WDFREQUEST, - call_unsafe_wdf_function_binding, + WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES, WDF_OBJECT_CONTEXT_TYPE_INFO, + WDF_TIMER_CONFIG, WDFDEVICE, WDFDEVICE_INIT, WDFDRIVER, WDFFILEOBJECT, WDFKEY, WDFOBJECT, + WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER, call_unsafe_wdf_function_binding, }; use crate::data_plane::{ChannelRole, DEFAULT_BUFFER_CAPACITY, DataPlaneError, EndpointDataPlane}; @@ -74,6 +76,13 @@ struct DeviceState { application_reads: AtomicPtr, daemon_reads: AtomicPtr, wait_requests: AtomicPtr, + pending_application_reads: Mutex>, +} + +#[derive(Debug, Clone, Copy)] +struct PendingRead { + request: usize, + deadline: Option, } impl DeviceState { @@ -87,6 +96,7 @@ impl DeviceState { application_reads: AtomicPtr::new(ptr::null_mut()), daemon_reads: AtomicPtr::new(ptr::null_mut()), wait_requests: AtomicPtr::new(ptr::null_mut()), + pending_application_reads: Mutex::new(VecDeque::new()), } } @@ -118,6 +128,12 @@ impl DeviceState { fn wait_queue(&self) -> WDFQUEUE { self.wait_requests.load(Ordering::Acquire) } + + fn pending_application_reads(&self) -> MutexGuard<'_, VecDeque> { + self.pending_application_reads + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } } static mut DEVICE_CONTEXT_TYPE_INFO: WDF_OBJECT_CONTEXT_TYPE_INFO = WDF_OBJECT_CONTEXT_TYPE_INFO { @@ -307,10 +323,50 @@ unsafe fn create_device(mut device_init: *mut WDFDEVICE_INIT) -> NTSTATUS { if !nt_success(status) { return status; } + // SAFETY: The device is live and owns the periodic timer for its full lifetime. + let status = unsafe { configure_read_timeout_timer(device) }; + if !nt_success(status) { + return status; + } // SAFETY: The device exists and registration consumes both counted strings synchronously. unsafe { register_interfaces(device) } } +unsafe fn configure_read_timeout_timer(device: WDFDEVICE) -> NTSTATUS { + const TIMER_PERIOD_MS: u32 = 10; + const FIRST_DUE_TIME_100NS: i64 = -100_000; + let mut config = WDF_TIMER_CONFIG { + Size: struct_size::(), + EvtTimerFunc: Some(evt_read_timeout_timer), + Period: TIMER_PERIOD_MS, + ..WDF_TIMER_CONFIG::default() + }; + let mut attributes = WDF_OBJECT_ATTRIBUTES { + Size: struct_size::(), + ParentObject: device.cast(), + ExecutionLevel: _WDF_EXECUTION_LEVEL::WdfExecutionLevelPassive, + SynchronizationScope: _WDF_SYNCHRONIZATION_SCOPE::WdfSynchronizationScopeNone, + ..WDF_OBJECT_ATTRIBUTES::default() + }; + let mut timer: WDFTIMER = ptr::null_mut(); + // SAFETY: WDF copies both configurations and returns a device-owned timer handle. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfTimerCreate, + &raw mut config, + &raw mut attributes, + &raw mut timer, + ) + }; + if !nt_success(status) { + return status; + } + // SAFETY: The timer was created successfully and accepts a relative 100ns due time. + let _was_queued = + unsafe { call_unsafe_wdf_function_binding!(WdfTimerStart, timer, FIRST_DUE_TIME_100NS) }; + STATUS_SUCCESS +} + unsafe fn configure_queues(device: WDFDEVICE, state: &DeviceState) -> NTSTATUS { let mut application_reads = ptr::null_mut(); // SAFETY: The device and output storage are valid for synchronous queue creation. @@ -536,6 +592,7 @@ unsafe extern "C" fn evt_file_cleanup(file: WDFFILEOBJECT) { }; if state.channel().close_if_owner(role, file) { println!("CatHub PoC {role:?} handle cleaned up"); + state.pending_application_reads().clear(); // SAFETY: Cleanup runs while the device and its child queues are alive. let _ = unsafe { apply_protocol_outputs(state, outputs) }; // SAFETY: This handle refers to a manual queue owned by this device. @@ -596,13 +653,35 @@ unsafe extern "C" fn evt_io_default(_queue: WDFQUEUE, request: WDFREQUEST) { }); } -unsafe extern "C" fn evt_io_canceled_on_queue(_queue: WDFQUEUE, request: WDFREQUEST) { +unsafe extern "C" fn evt_io_canceled_on_queue(queue: WDFQUEUE, request: WDFREQUEST) { ffi_void(|| { + // SAFETY: WDF keeps the queue's parent device alive for this callback. + if let Some(state) = unsafe { device_state_from_queue(queue) } { + state + .pending_application_reads() + .retain(|pending| pending.request != request.addr()); + } // SAFETY: WDF transfers ownership of the canceled request to the callback. unsafe { complete_request(request, STATUS_CANCELLED, 0) }; }); } +unsafe extern "C" fn evt_read_timeout_timer(timer: WDFTIMER) { + ffi_void(|| { + // SAFETY: WDF supplies a live timer whose parent is the device selected at creation. + let parent = unsafe { call_unsafe_wdf_function_binding!(WdfTimerGetParentObject, timer) }; + if parent.is_null() { + return; + } + // SAFETY: The timer is parented directly to this driver's WDFDEVICE. + let Some(state) = (unsafe { device_state(parent.cast()) }) else { + return; + }; + // SAFETY: The application read queue remains live while its parent device timer runs. + unsafe { service_expired_application_reads(state) }; + }); +} + unsafe fn handle_read( state: &DeviceState, request: WDFREQUEST, @@ -624,15 +703,34 @@ unsafe fn handle_read( }; match available { Ok(0) => { + let timeout_ms = if role == ChannelRole::Application { + state.serial().empty_read_timeout_ms(length) + } else { + None + }; + if timeout_ms == Some(0) { + return RequestDisposition::success(0); + } let queue = state.queue_for(role); if queue.is_null() { return RequestDisposition::error(STATUS_UNSUCCESSFUL); } + let mut pending = + (role == ChannelRole::Application).then(|| state.pending_application_reads()); // SAFETY: The request is framework-owned and target is a valid manual queue. let status = unsafe { call_unsafe_wdf_function_binding!(WdfRequestForwardToIoQueue, request, queue) }; if nt_success(status) { + if let Some(pending) = pending.as_mut() { + let deadline = timeout_ms.and_then(|milliseconds| { + Instant::now().checked_add(Duration::from_millis(milliseconds)) + }); + pending.push_back(PendingRead { + request: request.addr(), + deadline, + }); + } RequestDisposition::Pending } else { RequestDisposition::error(status) @@ -1180,6 +1278,8 @@ unsafe fn apply_protocol_outputs( unsafe fn service_pending_reads(state: &DeviceState, role: ChannelRole) { let queue = state.queue_for(role); + let mut pending_application = + (role == ChannelRole::Application).then(|| state.pending_application_reads()); loop { let available = match role { ChannelRole::Application => state.channel().plane.available_to_read(role), @@ -1200,6 +1300,9 @@ unsafe fn service_pending_reads(state: &DeviceState, role: ChannelRole) { if !nt_success(status) { break; } + if let Some(pending) = pending_application.as_mut() { + pending.retain(|entry| entry.request != request.addr()); + } // SAFETY: Retrieval transfers the queued request to this driver. let disposition = unsafe { read_available(state, request, role, usize::MAX) }; // SAFETY: The retrieved request is no longer owned by the manual queue. @@ -1207,6 +1310,45 @@ unsafe fn service_pending_reads(state: &DeviceState, role: ChannelRole) { } } +unsafe fn service_expired_application_reads(state: &DeviceState) { + let queue = state.queue_for(ChannelRole::Application); + if queue.is_null() { + return; + } + let mut pending = state.pending_application_reads(); + loop { + let expired = pending + .front() + .and_then(|entry| entry.deadline) + .is_some_and(|deadline| deadline <= Instant::now()); + if !expired { + break; + } + let expected = pending.front().map(|entry| entry.request); + let mut request: WDFREQUEST = ptr::null_mut(); + // SAFETY: Queue is a valid manual queue and request is valid output storage. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfIoQueueRetrieveNextRequest, + queue, + &raw mut request, + ) + }; + if !nt_success(status) { + pending.clear(); + break; + } + pending.pop_front(); + if expected != Some(request.addr()) { + pending.retain(|entry| entry.request != request.addr()); + } + drop(pending); + // SAFETY: Retrieval transfers ownership of this expired request to the driver. + unsafe { complete_request(request, STATUS_SUCCESS, 0) }; + pending = state.pending_application_reads(); + } +} + unsafe fn drain_pending_reads(queue: WDFQUEUE, status: NTSTATUS) { if queue.is_null() { return; diff --git a/drivers/cathub-virtual-serial-umdf/src/serial.rs b/drivers/cathub-virtual-serial-umdf/src/serial.rs index 4a5bed3..0beb4b0 100644 --- a/drivers/cathub-virtual-serial-umdf/src/serial.rs +++ b/drivers/cathub-virtual-serial-umdf/src/serial.rs @@ -320,6 +320,27 @@ impl SerialState { Ok(()) } + /// Compute the total timeout for a read that currently has no available bytes. + /// + /// `None` means wait indefinitely. `Some(0)` is the Windows special non-blocking + /// configuration (`ReadIntervalTimeout == MAXDWORD` with both totals zero). + #[must_use] + pub fn empty_read_timeout_ms(&self, requested_bytes: usize) -> Option { + let timeouts = self.timeouts; + if timeouts.read_interval_timeout == u32::MAX + && timeouts.read_total_timeout_multiplier == 0 + && timeouts.read_total_timeout_constant == 0 + { + return Some(0); + } + let multiplier = u64::from(timeouts.read_total_timeout_multiplier); + let requested = u64::try_from(requested_bytes).unwrap_or(u64::MAX); + let total = multiplier + .saturating_mul(requested) + .saturating_add(u64::from(timeouts.read_total_timeout_constant)); + (total != 0).then_some(total) + } + pub const fn set_queue_size(&self, value: SerialQueueSize) -> Result<(), SerialStateError> { if value.input_size > self.input_queue_limit || value.output_size > self.output_queue_limit { @@ -587,6 +608,28 @@ mod tests { ); } + #[test] + fn computes_empty_read_timeout_from_windows_totals() { + let mut state = SerialState::default(); + assert_eq!(state.empty_read_timeout_ms(10), None); + state + .set_timeouts(SerialTimeouts { + read_total_timeout_multiplier: 3, + read_total_timeout_constant: 70, + ..SerialTimeouts::default() + }) + .expect("timeouts"); + assert_eq!(state.empty_read_timeout_ms(10), Some(100)); + + state + .set_timeouts(SerialTimeouts { + read_interval_timeout: u32::MAX, + ..SerialTimeouts::default() + }) + .expect("immediate timeouts"); + assert_eq!(state.empty_read_timeout_ms(10), Some(0)); + } + #[test] fn wait_events_are_masked_and_consumed() { let mut state = SerialState::default(); diff --git a/scripts/Test-UmdfEndToEnd.ps1 b/scripts/Test-UmdfEndToEnd.ps1 index 2e2a333..f05e60a 100644 --- a/scripts/Test-UmdfEndToEnd.ps1 +++ b/scripts/Test-UmdfEndToEnd.ps1 @@ -145,6 +145,25 @@ try { $serial.DiscardInBuffer() $serial.DiscardOutBuffer() + $serial.ReadTimeout = 100 + $timer = [System.Diagnostics.Stopwatch]::StartNew() + try { + $null = $serial.ReadByte() + throw 'An empty serial read unexpectedly returned data.' + } + catch [System.TimeoutException] { + $timer.Stop() + } + if ($timer.ElapsedMilliseconds -lt 50 -or $timer.ElapsedMilliseconds -gt 1000) { + throw "Empty read timed out after $($timer.ElapsedMilliseconds) ms." + } + $results.cases += [ordered]@{ + name = 'read_timeout' + passed = $true + elapsed_ms = $timer.ElapsedMilliseconds + } + $serial.ReadTimeout = 5000 + $id = Invoke-CatQuery -Port $serial -Command 'ID;' if ($id -ne 'ID021;') { throw "Unexpected ID response '$id'." From 3a710c880e794429dbb614de6a89e8be780a2d58 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 20:17:07 -0700 Subject: [PATCH 11/39] Provision stable UMDF endpoint identities --- .../cathub_virtual_serial_umdf.inx | 147 +++++++++++++++++- .../cathub-virtual-serial-umdf/src/interop.rs | 95 ++++++++++- .../src/private_protocol.rs | 59 ++++++- 3 files changed, 293 insertions(+), 8 deletions(-) diff --git a/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx b/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx index c2ebf88..860c279 100644 --- a/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx +++ b/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx @@ -22,6 +22,11 @@ cathub_virtual_serial_umdf.dll = 1,, [Standard.NT$ARCH$.10.0...22000] %DeviceDesc%=CatHubUMDFDevice_Install, root\CATHUB_VIRTUAL_SERIAL +%HdsdrDeviceDesc%=CatHubHdsdr_Install, root\CATHUB_HDSDR_CAT +%N1mmCatDeviceDesc%=CatHubN1mmCat_Install, root\CATHUB_N1MM_CAT +%ArcpDeviceDesc%=CatHubArcp_Install, root\CATHUB_ARCP590_CAT +%N1mmWinkeyerDeviceDesc%=CatHubN1mmWinkeyer_Install, root\CATHUB_N1MM_WINKEYER +%WktoolsDeviceDesc%=CatHubWktools_Install, root\CATHUB_WKTOOLS [CatHubUMDFDevice_Install.NT] CopyFiles=DriverCopy @@ -32,7 +37,7 @@ Needs=WUDFRD.NT cathub_virtual_serial_umdf.dll [CatHubUMDFDevice_Install.NT.HW] -AddReg=SetDeviceType_AddReg +AddReg=SetDeviceType_AddReg,DefaultMetadata_AddReg Include=WUDFRD.inf Needs=WUDFRD.NT.HW @@ -47,6 +52,111 @@ UmdfKernelModeClientPolicy=AllowKernelModeClients UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects UmdfFsContextUsePolicy=CannotUseFsContexts +[CatHubHdsdr_Install.NT] +CopyFiles=DriverCopy +Include=WUDFRD.inf +Needs=WUDFRD.NT + +[CatHubHdsdr_Install.NT.HW] +AddReg=SetDeviceType_AddReg,HdsdrMetadata_AddReg +Include=WUDFRD.inf +Needs=WUDFRD.NT.HW + +[CatHubHdsdr_Install.NT.Services] +Include=WUDFRD.inf +Needs=WUDFRD.NT.Services + +[CatHubHdsdr_Install.NT.Wdf] +UmdfService=cathub_virtual_serial_umdf, CatHubUMDFDevice_WdfInstall +UmdfServiceOrder=cathub_virtual_serial_umdf +UmdfKernelModeClientPolicy=AllowKernelModeClients +UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects +UmdfFsContextUsePolicy=CannotUseFsContexts + +[CatHubN1mmCat_Install.NT] +CopyFiles=DriverCopy +Include=WUDFRD.inf +Needs=WUDFRD.NT + +[CatHubN1mmCat_Install.NT.HW] +AddReg=SetDeviceType_AddReg,N1mmCatMetadata_AddReg +Include=WUDFRD.inf +Needs=WUDFRD.NT.HW + +[CatHubN1mmCat_Install.NT.Services] +Include=WUDFRD.inf +Needs=WUDFRD.NT.Services + +[CatHubN1mmCat_Install.NT.Wdf] +UmdfService=cathub_virtual_serial_umdf, CatHubUMDFDevice_WdfInstall +UmdfServiceOrder=cathub_virtual_serial_umdf +UmdfKernelModeClientPolicy=AllowKernelModeClients +UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects +UmdfFsContextUsePolicy=CannotUseFsContexts + +[CatHubArcp_Install.NT] +CopyFiles=DriverCopy +Include=WUDFRD.inf +Needs=WUDFRD.NT + +[CatHubArcp_Install.NT.HW] +AddReg=SetDeviceType_AddReg,ArcpMetadata_AddReg +Include=WUDFRD.inf +Needs=WUDFRD.NT.HW + +[CatHubArcp_Install.NT.Services] +Include=WUDFRD.inf +Needs=WUDFRD.NT.Services + +[CatHubArcp_Install.NT.Wdf] +UmdfService=cathub_virtual_serial_umdf, CatHubUMDFDevice_WdfInstall +UmdfServiceOrder=cathub_virtual_serial_umdf +UmdfKernelModeClientPolicy=AllowKernelModeClients +UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects +UmdfFsContextUsePolicy=CannotUseFsContexts + +[CatHubN1mmWinkeyer_Install.NT] +CopyFiles=DriverCopy +Include=WUDFRD.inf +Needs=WUDFRD.NT + +[CatHubN1mmWinkeyer_Install.NT.HW] +AddReg=SetDeviceType_AddReg,N1mmWinkeyerMetadata_AddReg +Include=WUDFRD.inf +Needs=WUDFRD.NT.HW + +[CatHubN1mmWinkeyer_Install.NT.Services] +Include=WUDFRD.inf +Needs=WUDFRD.NT.Services + +[CatHubN1mmWinkeyer_Install.NT.Wdf] +UmdfService=cathub_virtual_serial_umdf, CatHubUMDFDevice_WdfInstall +UmdfServiceOrder=cathub_virtual_serial_umdf +UmdfKernelModeClientPolicy=AllowKernelModeClients +UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects +UmdfFsContextUsePolicy=CannotUseFsContexts + +[CatHubWktools_Install.NT] +CopyFiles=DriverCopy +Include=WUDFRD.inf +Needs=WUDFRD.NT + +[CatHubWktools_Install.NT.HW] +AddReg=SetDeviceType_AddReg,WktoolsMetadata_AddReg +Include=WUDFRD.inf +Needs=WUDFRD.NT.HW + +[CatHubWktools_Install.NT.Services] +Include=WUDFRD.inf +Needs=WUDFRD.NT.Services + +[CatHubWktools_Install.NT.Wdf] +UmdfService=cathub_virtual_serial_umdf, CatHubUMDFDevice_WdfInstall +UmdfServiceOrder=cathub_virtual_serial_umdf +UmdfKernelModeClientPolicy=AllowKernelModeClients +UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects +UmdfFsContextUsePolicy=CannotUseFsContexts + [CatHubUMDFDevice_WdfInstall] UmdfLibraryVersion=$UMDFVERSION$ ServiceBinary=%13%\cathub_virtual_serial_umdf.dll @@ -54,8 +164,43 @@ ServiceBinary=%13%\cathub_virtual_serial_umdf.dll [SetDeviceType_AddReg] HKR,,DeviceType,0x10001,0x0000001b +[DefaultMetadata_AddReg] +HKR,,CatHubEndpointKind,0x10001,1 +HKR,,CatHubStableId,0x00000000,"cathub-default" +HKR,,CatHubDisplayName,0x00000000,"CatHub Virtual Serial Port" + +[HdsdrMetadata_AddReg] +HKR,,CatHubEndpointKind,0x10001,1 +HKR,,CatHubStableId,0x00000000,"hdsdr-cat" +HKR,,CatHubDisplayName,0x00000000,"CatHub HDSDR CAT Port" + +[N1mmCatMetadata_AddReg] +HKR,,CatHubEndpointKind,0x10001,1 +HKR,,CatHubStableId,0x00000000,"n1mm-cat" +HKR,,CatHubDisplayName,0x00000000,"CatHub N1MM CAT Port" + +[ArcpMetadata_AddReg] +HKR,,CatHubEndpointKind,0x10001,1 +HKR,,CatHubStableId,0x00000000,"arcp590-cat" +HKR,,CatHubDisplayName,0x00000000,"CatHub ARCP-590 CAT Port" + +[N1mmWinkeyerMetadata_AddReg] +HKR,,CatHubEndpointKind,0x10001,2 +HKR,,CatHubStableId,0x00000000,"n1mm-winkeyer" +HKR,,CatHubDisplayName,0x00000000,"CatHub N1MM WinKeyer Port" + +[WktoolsMetadata_AddReg] +HKR,,CatHubEndpointKind,0x10001,2 +HKR,,CatHubStableId,0x00000000,"wktools" +HKR,,CatHubDisplayName,0x00000000,"CatHub WKTools Port" + [Strings] ProviderString="CatHub contributors" ManufacturerName="CatHub contributors" DiskId1="CatHub virtual serial installation media" DeviceDesc="CatHub Virtual Serial Port" +HdsdrDeviceDesc="CatHub HDSDR CAT Port" +N1mmCatDeviceDesc="CatHub N1MM CAT Port" +ArcpDeviceDesc="CatHub ARCP-590 CAT Port" +N1mmWinkeyerDeviceDesc="CatHub N1MM WinKeyer Port" +WktoolsDeviceDesc="CatHub WKTools Port" diff --git a/drivers/cathub-virtual-serial-umdf/src/interop.rs b/drivers/cathub-virtual-serial-umdf/src/interop.rs index 46dd81a..c1d9e44 100644 --- a/drivers/cathub-virtual-serial-umdf/src/interop.rs +++ b/drivers/cathub-virtual-serial-umdf/src/interop.rs @@ -25,7 +25,9 @@ use wdk_sys::{ }; use crate::data_plane::{ChannelRole, DEFAULT_BUFFER_CAPACITY, DataPlaneError, EndpointDataPlane}; -use crate::private_protocol::{DriverProtocol, DriverProtocolError, ProtocolOutput}; +use crate::private_protocol::{ + DriverProtocol, DriverProtocolError, EndpointMetadata, ProtocolOutput, +}; use crate::serial::{ SerialBaudRate, SerialChars, SerialCommProperties, SerialHandflow, SerialLineControl, SerialQueueSize, SerialState, SerialStateError, SerialStatus, SerialTimeouts, ioctl, purge, @@ -44,6 +46,15 @@ const TRUE: BOOLEAN = 1; const DAEMON_REFERENCE: [u16; 7] = [100, 97, 101, 109, 111, 110, 0]; const PORT_NAME_VALUE: [u16; 9] = [80, 111, 114, 116, 78, 97, 109, 101, 0]; +const ENDPOINT_KIND_VALUE: [u16; 19] = [ + 67, 97, 116, 72, 117, 98, 69, 110, 100, 112, 111, 105, 110, 116, 75, 105, 110, 100, 0, +]; +const STABLE_ID_VALUE: [u16; 15] = [ + 67, 97, 116, 72, 117, 98, 83, 116, 97, 98, 108, 101, 73, 100, 0, +]; +const DISPLAY_NAME_VALUE: [u16; 18] = [ + 67, 97, 116, 72, 117, 98, 68, 105, 115, 112, 108, 97, 121, 78, 97, 109, 101, 0, +]; const DOS_DEVICE_PREFIX: &[u16] = &[ 92, 68, 111, 115, 68, 101, 118, 105, 99, 101, 115, 92, 71, 108, 111, 98, 97, 108, 92, ]; @@ -86,12 +97,17 @@ struct PendingRead { } impl DeviceState { + #[cfg(test)] fn new() -> Self { + Self::for_endpoint(EndpointMetadata::default()) + } + + fn for_endpoint(endpoint: EndpointMetadata) -> Self { let mut serial = SerialState::default(); serial.set_modem_input(serial.modem_input()); Self { channel: Mutex::new(ChannelState::new()), - protocol: Mutex::new(DriverProtocol::new()), + protocol: Mutex::new(DriverProtocol::for_endpoint(endpoint)), serial: Mutex::new(serial), application_reads: AtomicPtr::new(ptr::null_mut()), daemon_reads: AtomicPtr::new(ptr::null_mut()), @@ -313,7 +329,9 @@ unsafe fn create_device(mut device_init: *mut WDFDEVICE_INIT) -> NTSTATUS { let Some(context) = (unsafe { device_context(device) }) else { return STATUS_UNSUCCESSFUL; }; - let state = Box::into_raw(Box::new(DeviceState::new())); + // SAFETY: The live WDF device owns a queryable PnP instance registry key. + let endpoint = unsafe { read_endpoint_metadata(device) }; + let state = Box::into_raw(Box::new(DeviceState::for_endpoint(endpoint))); // SAFETY: The context is exclusively initialized before queues or interfaces publish device. unsafe { (*context).state = state }; // SAFETY: `state` remains owned by the WDF device context until its destroy callback. @@ -469,6 +487,77 @@ unsafe fn register_interfaces(device: WDFDEVICE) -> NTSTATUS { } } +unsafe fn read_endpoint_metadata(device: WDFDEVICE) -> EndpointMetadata { + let mut metadata = EndpointMetadata::default(); + let mut key: WDFKEY = ptr::null_mut(); + // SAFETY: The device is live, null attributes are allowed, and output storage is valid. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceOpenRegistryKey, + device, + PLUGPLAY_REGKEY_DEVICE, + KEY_QUERY_VALUE, + WDF_NO_OBJECT_ATTRIBUTES, + &raw mut key, + ) + }; + if !nt_success(status) { + return metadata; + } + + let mut kind = 0_u32; + let kind_name = unicode_string(&ENDPOINT_KIND_VALUE); + // SAFETY: The key is open and both the value name and output remain valid synchronously. + let kind_status = unsafe { + call_unsafe_wdf_function_binding!( + WdfRegistryQueryULong, + key, + &raw const kind_name, + &raw mut kind, + ) + }; + if nt_success(kind_status) && matches!(kind, 1 | 2) { + metadata.kind = u16::try_from(kind).unwrap_or(1); + } + // SAFETY: The key remains open through both synchronous string queries. + if let Some(stable_id) = unsafe { query_registry_string(key, &STABLE_ID_VALUE) } { + metadata.stable_id = stable_id; + } + // SAFETY: The key remains open through this synchronous string query. + if let Some(display_name) = unsafe { query_registry_string(key, &DISPLAY_NAME_VALUE) } { + metadata.display_name = display_name; + } + // SAFETY: This driver owns the WDF registry-key handle returned above. + unsafe { call_unsafe_wdf_function_binding!(WdfRegistryClose, key) }; + metadata +} + +unsafe fn query_registry_string(key: WDFKEY, value: &[u16]) -> Option { + let value_name = unicode_string(value); + let mut buffer = [0_u16; 128]; + let mut output = unicode_string_buffer(&mut buffer); + // SAFETY: The key is open and both counted strings remain valid through the call. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfRegistryQueryUnicodeString, + key, + &raw const value_name, + ptr::null_mut(), + &raw mut output, + ) + }; + if !nt_success(status) { + return None; + } + let units = usize::from(output.Length) / size_of::(); + let value = String::from_utf16(buffer.get(..units)?) + .ok()? + .trim_matches('\0') + .trim() + .to_owned(); + (!value.is_empty()).then_some(value) +} + unsafe fn create_com_symbolic_link(device: WDFDEVICE) -> NTSTATUS { let mut key: WDFKEY = ptr::null_mut(); // SAFETY: The device is live, null attributes are allowed, and output storage is valid. diff --git a/drivers/cathub-virtual-serial-umdf/src/private_protocol.rs b/drivers/cathub-virtual-serial-umdf/src/private_protocol.rs index 60436fe..16bd7ba 100644 --- a/drivers/cathub-virtual-serial-umdf/src/private_protocol.rs +++ b/drivers/cathub-virtual-serial-umdf/src/private_protocol.rs @@ -62,9 +62,31 @@ enum ProtocolPhase { Ready, } +/// Provisioned identity for one public COM device instance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EndpointMetadata { + /// Endpoint kind: 1 is CAT and 2 is `WinKeyer`. + pub kind: u16, + /// Stable identifier referenced by `CatHub` configuration. + pub stable_id: String, + /// Operator-facing endpoint description. + pub display_name: String, +} + +impl Default for EndpointMetadata { + fn default() -> Self { + Self { + kind: ENDPOINT_KIND_CAT, + stable_id: ENDPOINT_STABLE_ID.to_owned(), + display_name: ENDPOINT_DISPLAY_NAME.to_owned(), + } + } +} + /// Protocol state owned by one driver device instance. #[derive(Debug)] pub struct DriverProtocol { + endpoint: EndpointMetadata, decoder: FrameDecoder, phase: ProtocolPhase, attached: bool, @@ -82,7 +104,14 @@ impl DriverProtocol { /// Create a disconnected protocol endpoint. #[must_use] pub fn new() -> Self { + Self::for_endpoint(EndpointMetadata::default()) + } + + /// Create a disconnected protocol endpoint with a provisioned identity. + #[must_use] + pub fn for_endpoint(endpoint: EndpointMetadata) -> Self { Self { + endpoint, decoder: FrameDecoder::default(), phase: ProtocolPhase::AwaitHello, attached: false, @@ -101,7 +130,8 @@ impl DriverProtocol { pub fn reset_daemon(&mut self) { let application_open = self.application_open; let application_session = self.application_session; - *self = Self::new(); + let endpoint = self.endpoint.clone(); + *self = Self::for_endpoint(endpoint); self.application_open = application_open; self.application_session = application_session; } @@ -361,13 +391,13 @@ impl DriverProtocol { endpoint.endpoint_id = ENDPOINT_ID; endpoint .fields - .insert_u16(endpoint_field::KIND, ENDPOINT_KIND_CAT)?; + .insert_u16(endpoint_field::KIND, self.endpoint.kind)?; endpoint .fields - .insert_string(endpoint_field::STABLE_ID, ENDPOINT_STABLE_ID)?; + .insert_string(endpoint_field::STABLE_ID, &self.endpoint.stable_id)?; endpoint .fields - .insert_string(endpoint_field::DISPLAY_NAME, ENDPOINT_DISPLAY_NAME)?; + .insert_string(endpoint_field::DISPLAY_NAME, &self.endpoint.display_name)?; endpoint.fields.insert_u16(endpoint_field::ENABLED, 1)?; let complete = response_frame(MessageKind::DiscoverComplete, frame, true); Ok(vec![ @@ -659,6 +689,27 @@ mod tests { ); } + #[test] + fn discovery_reports_provisioned_endpoint_identity() { + let mut protocol = DriverProtocol::for_endpoint(EndpointMetadata { + kind: 2, + stable_id: "n1mm-winkeyer".to_owned(), + display_name: "CatHub N1MM WinKeyer Port".to_owned(), + }); + ingest_frame(&mut protocol, &hello(1)); + let discovery = ingest_frame(&mut protocol, &Frame::new(MessageKind::Discover, 0, 2)); + let endpoint = output_frame(&discovery[0]); + assert_eq!(endpoint.fields.require_u16(endpoint_field::KIND), Ok(2)); + assert_eq!( + endpoint.fields.require_string(endpoint_field::STABLE_ID), + Ok("n1mm-winkeyer") + ); + assert_eq!( + endpoint.fields.require_string(endpoint_field::DISPLAY_NAME), + Ok("CatHub N1MM WinKeyer Port") + ); + } + #[test] fn stream_decoder_handles_split_hello() { let mut protocol = DriverProtocol::new(); From 1b4973d1c0d6f22231e3eb5db884ad64a6932bd3 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 20:31:15 -0700 Subject: [PATCH 12/39] Provision CatHub-owned virtual COM endpoints --- README.md | 6 +- config/cathub.toml | 30 +- crates/cathub/Cargo.toml | 2 + crates/cathub/src/config.rs | 54 + crates/cathub/src/lib.rs | 96 ++ .../cathub/src/virtual_serial_provisioning.rs | 1349 +++++++++++++++++ docs/integration/windows-virtual-serial.md | 87 ++ scripts/Test-UmdfEndToEnd.ps1 | 11 +- 8 files changed, 1612 insertions(+), 23 deletions(-) create mode 100644 crates/cathub/src/virtual_serial_provisioning.rs create mode 100644 docs/integration/windows-virtual-serial.md diff --git a/README.md b/README.md index afe94a7..e91e1b9 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,9 @@ Clients must validate that process ID before they use the endpoint. ## Client interfaces - Hamlib-aware clients connect to a configured `[[hamlib_net]]` TCP listener. -- Serial CAT clients connect to the application side of a dedicated virtual serial pair. -- Legacy WinKeyer clients connect to their own virtual serial pair. +- On Windows, serial CAT clients can connect to a single CatHub-owned UMDF COM endpoint; existing + physical and externally provisioned serial transports remain supported. +- Legacy WinKeyer clients can use their own CatHub-owned UMDF COM endpoint. - Typed WinKeyer clients connect to the loopback gRPC address in `[winkeyer].api_bind`. A launcher-managed client uses the endpoint in CatHub's runtime file. @@ -165,6 +166,7 @@ Registry publication is a separate authorized operation. See - [Virtual serial Phase 1](docs/design/virtual-serial-transport-phase-1.md) - [Serial client inventory](docs/testing/serial-client-inventory.md) - [Operator setup](docs/integration/setup.md) +- [Windows CatHub-owned virtual serial](docs/integration/windows-virtual-serial.md) - [Release and compatibility](docs/architecture/release-and-compatibility.md) ## License diff --git a/config/cathub.toml b/config/cathub.toml index 33f5760..66b94b1 100644 --- a/config/cathub.toml +++ b/config/cathub.toml @@ -7,8 +7,9 @@ # Validate without touching hardware: # cargo run -p cathub -- --config config/cathub.toml --dry-run # -# Virtual serial pairs (com0com): the daemon binds the first port of each pair, the -# application binds the second. Create one pair per serial client. +# CatHub-managed virtual serial endpoints expose one application-facing COM port each. +# The daemon connects through the private UMDF interface selected by `virtual_endpoint`. +# Provision them with `cathub --config config/cathub.toml virtual-serial apply --inf `. [radio] backend = "ts590" # first-class native Kenwood TS-590 driver (no Hamlib linked) @@ -30,8 +31,7 @@ native_push = true # daemon enables and owns the TS-590 AI2; stream # --- WinKeyer broker ------------------------------------------------------------------ # # CatHub owns the physical keyer. Typed clients use the loopback API; legacy clients use -# the application side of a dedicated virtual serial pair. Change COM40/COM41 if that pair -# is not installed on this station. +# a dedicated CatHub-managed COM endpoint. [winkeyer] port = "COM3" @@ -41,7 +41,7 @@ api_bind = "127.0.0.1:50071" [[winkeyer_endpoint]] name = "n1mm-cw" -transport = "COM40" # N1MM WinKeyer port is the paired COM41 +virtual_endpoint = "n1mm-winkeyer" application_transport = "COM41" baud = 1200 primary = true @@ -49,7 +49,7 @@ perms = ["status", "send", "control", "ptt"] [[winkeyer_endpoint]] name = "wktools-maintenance" -transport = "COM42" # WKTools maintenance port is the paired COM43 +virtual_endpoint = "wktools" application_transport = "COM43" baud = 1200 primary = false @@ -92,16 +92,10 @@ bind = "127.0.0.1:4534" single_vfo = true perms = ["read", "write"] -# --- Serial endpoints (com0com virtual pairs) ---------------------------------------------- +# --- CatHub-managed serial endpoints --------------------------------------------------- # -# IMPORTANT: each com0com pair has TWO port numbers. The daemon binds the port named below -# (`transport`); your application must connect to the OTHER port in the same pair. Never -# point an application at the daemon's port -- they cannot both open the same port. -# -# pair daemon binds (transport) application connects to -# COM10 <-> COM11 COM10 COM11 (HDSDR / OmniRig) -# COM20 <-> COM21 COM20 COM21 (N1MM) -# COM30 <-> COM31 COM30 COM31 (ARCP-590) +# Each application opens `application_transport`; CatHub attaches to the same device through +# its private interface. There is no daemon-side COM port and no null-modem pair. # HDSDR via OmniRig as a TS-2000-style controller. The panadapter follows the radio, and # click-to-tune on the waterfall may set frequency. Mode writes are denied so OmniRig cannot @@ -110,7 +104,7 @@ perms = ["read", "write"] # can never oscillate the TS-590's A/B VFO selection. [[serial_endpoint]] name = "hdsdr-omnirig" -transport = "COM10" # OmniRig binds COM11 +virtual_endpoint = "hdsdr-cat" application_transport = "COM11" baud = 115200 dialect = "ts2000" @@ -122,7 +116,7 @@ perms = ["read", "frequency_write"] # whichever VFO the radio is actually on, so A/B switching tracks seamlessly. [[serial_endpoint]] name = "n1mm" -transport = "COM20" # N1MM binds COM21 +virtual_endpoint = "n1mm-cat" application_transport = "COM21" baud = 115200 dialect = "ts590" @@ -134,7 +128,7 @@ perms = ["read", "write", "ptt"] # single_vfo off (the default). [[serial_endpoint]] name = "arcp590" -transport = "COM30" # ARCP-590 binds COM31 +virtual_endpoint = "arcp590-cat" application_transport = "COM31" baud = 115200 dialect = "ts590" diff --git a/crates/cathub/Cargo.toml b/crates/cathub/Cargo.toml index 3dcd54f..c6b81a3 100644 --- a/crates/cathub/Cargo.toml +++ b/crates/cathub/Cargo.toml @@ -39,6 +39,8 @@ tracing-subscriber = { workspace = true } windows-sys = { version = "0.59", features = [ "Win32_Devices_DeviceAndDriverInstallation", "Win32_Foundation", + "Win32_System_Registry", + "Win32_UI_Shell", ] } [dev-dependencies] diff --git a/crates/cathub/src/config.rs b/crates/cathub/src/config.rs index 5d47cf9..8f2981c 100644 --- a/crates/cathub/src/config.rs +++ b/crates/cathub/src/config.rs @@ -418,6 +418,35 @@ impl Config { )); } self.validate_winkeyer()?; + self.validate_managed_virtual_endpoints()?; + Ok(()) + } + + fn validate_managed_virtual_endpoints(&self) -> Result<(), ConfigError> { + let mut stable_ids = std::collections::BTreeSet::new(); + for (kind, name, stable_id) in self + .serial_endpoint + .iter() + .filter_map(|endpoint| { + endpoint + .virtual_endpoint + .as_deref() + .map(|stable_id| ("serial", endpoint.name.as_str(), stable_id)) + }) + .chain(self.winkeyer_endpoint.iter().filter_map(|endpoint| { + endpoint + .virtual_endpoint + .as_deref() + .map(|stable_id| ("WinKeyer", endpoint.name.as_str(), stable_id)) + })) + { + let normalized = stable_id.trim().to_ascii_lowercase(); + if !stable_ids.insert(normalized) { + return Err(ConfigError::Invalid(format!( + "managed virtual endpoint '{stable_id}' is configured more than once (including {kind} endpoint '{name}')" + ))); + } + } Ok(()) } @@ -867,6 +896,31 @@ dialect = "ts590" .contains("requires exactly one of transport or virtual_endpoint")); } + #[test] + fn rejects_duplicate_managed_identity_across_cat_and_winkeyer() { + let error = Config::parse( + r#" +[radio] +backend = "loopback" + +[[serial_endpoint]] +name = "cat" +virtual_endpoint = "cathub-default" +dialect = "ts590" + +[winkeyer] +port = "COM3" + +[[winkeyer_endpoint]] +name = "keyer" +virtual_endpoint = "CATHUB-DEFAULT" +"#, + ) + .expect_err("one driver endpoint cannot serve two configured sessions"); + + assert!(error.to_string().contains("configured more than once")); + } + const SAMPLE: &str = r#" [radio] backend = "ts590" diff --git a/crates/cathub/src/lib.rs b/crates/cathub/src/lib.rs index 055c461..28d2af8 100644 --- a/crates/cathub/src/lib.rs +++ b/crates/cathub/src/lib.rs @@ -33,6 +33,7 @@ mod radio; mod runtime_info; mod serial_endpoint; mod state; +mod virtual_serial_provisioning; mod winkeyer; #[cfg(test)] @@ -132,6 +133,47 @@ pub enum Command { #[command(subcommand)] command: ConfigCommand, }, + /// Inspect or provision CatHub-owned Windows virtual COM endpoints. + VirtualSerial { + /// Virtual serial operation to perform. + #[command(subcommand)] + command: VirtualSerialCommand, + }, +} + +/// CatHub-owned Windows virtual serial operations. +#[derive(Debug, Subcommand)] +pub enum VirtualSerialCommand { + /// Report CatHub-owned PnP devices and COM claims without changing the system. + Status { + /// Select text or machine-readable JSON output. + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, + /// Compare configured managed endpoints with PnP and COM Name Arbiter state. + Plan { + /// Select text or machine-readable JSON output. + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, + /// Reconcile configured managed endpoints using a signed CatHub driver package. + Apply { + /// Full path to the CatHub UMDF driver INF. + #[arg(long, value_name = "FILE")] + inf: PathBuf, + /// Select text or machine-readable JSON output. + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, + /// Remove CatHub-owned endpoint devices, never third-party or physical ports. + Remove { + /// Stable endpoint ID to remove; repeat to select several. Omit to remove all. + #[arg(long = "endpoint", value_name = "STABLE_ID")] + endpoints: Vec, + /// Select text or machine-readable JSON output. + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, } /// CatHub configuration commands. @@ -683,6 +725,60 @@ fn run_command( Ok(()) } }, + Command::VirtualSerial { command } => { + run_virtual_serial_command(command, config_path.as_deref(), section) + } + } +} + +fn run_virtual_serial_command( + command: VirtualSerialCommand, + config_path: Option<&std::path::Path>, + section: Option<&str>, +) -> Result<(), error::ConfigError> { + use virtual_serial_provisioning as provisioning; + + fn print_report( + report: &T, + format: OutputFormat, + ) -> Result<(), error::ConfigError> { + match format { + OutputFormat::Text => println!("{}", report.render_text()), + OutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(report).map_err(|error| { + error::ConfigError::Invalid(format!( + "serializing virtual serial report: {error}" + )) + })? + ), + } + Ok(()) + } + + let load_config = || { + let path = config_path.map_or_else(Config::default_config_path, PathBuf::from); + Config::load_selected(&path, section) + }; + match command { + VirtualSerialCommand::Status { format } => { + let report = provisioning::status().map_err(error::ConfigError::Invalid)?; + print_report(&report, format) + } + VirtualSerialCommand::Plan { format } => { + let report = + provisioning::plan(&load_config()?).map_err(error::ConfigError::Invalid)?; + print_report(&report, format) + } + VirtualSerialCommand::Apply { inf, format } => { + let report = + provisioning::apply(&load_config()?, &inf).map_err(error::ConfigError::Invalid)?; + print_report(&report, format) + } + VirtualSerialCommand::Remove { endpoints, format } => { + let report = provisioning::remove(&endpoints).map_err(error::ConfigError::Invalid)?; + print_report(&report, format) + } } } diff --git a/crates/cathub/src/virtual_serial_provisioning.rs b/crates/cathub/src/virtual_serial_provisioning.rs new file mode 100644 index 0000000..c187ace --- /dev/null +++ b/crates/cathub/src/virtual_serial_provisioning.rs @@ -0,0 +1,1349 @@ +//! Idempotent provisioning for CatHub-owned UMDF virtual serial endpoints. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use crate::config::Config; + +/// One endpoint identity compiled into the CatHub UMDF package. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct EndpointDefinition { + stable_id: &'static str, + kind: EndpointKind, + hardware_id: &'static str, + display_name: &'static str, + default_port: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum EndpointKind { + Cat, + Winkeyer, +} + +const ENDPOINTS: [EndpointDefinition; 6] = [ + EndpointDefinition { + stable_id: "cathub-default", + kind: EndpointKind::Cat, + hardware_id: r"ROOT\CATHUB_VIRTUAL_SERIAL", + display_name: "CatHub Virtual Serial Port", + default_port: "COM21", + }, + EndpointDefinition { + stable_id: "hdsdr-cat", + kind: EndpointKind::Cat, + hardware_id: r"ROOT\CATHUB_HDSDR_CAT", + display_name: "CatHub HDSDR CAT Port", + default_port: "COM11", + }, + EndpointDefinition { + stable_id: "n1mm-cat", + kind: EndpointKind::Cat, + hardware_id: r"ROOT\CATHUB_N1MM_CAT", + display_name: "CatHub N1MM CAT Port", + default_port: "COM21", + }, + EndpointDefinition { + stable_id: "arcp590-cat", + kind: EndpointKind::Cat, + hardware_id: r"ROOT\CATHUB_ARCP590_CAT", + display_name: "CatHub ARCP-590 CAT Port", + default_port: "COM31", + }, + EndpointDefinition { + stable_id: "n1mm-winkeyer", + kind: EndpointKind::Winkeyer, + hardware_id: r"ROOT\CATHUB_N1MM_WINKEYER", + display_name: "CatHub N1MM WinKeyer Port", + default_port: "COM41", + }, + EndpointDefinition { + stable_id: "wktools", + kind: EndpointKind::Winkeyer, + hardware_id: r"ROOT\CATHUB_WKTOOLS", + display_name: "CatHub WKTools Port", + default_port: "COM43", + }, +]; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct DesiredEndpoint { + stable_id: String, + kind: EndpointKind, + hardware_id: String, + display_name: String, + com_port: String, + configured_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct InstalledEndpoint { + stable_id: String, + kind: EndpointKind, + hardware_id: String, + instance_id: String, + display_name: String, + com_port: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct PortClaim { + com_port: String, + owner: String, + cathub_owned: bool, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct SystemSnapshot { + owned: Vec, + claims: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub(crate) enum ProvisionAction { + Create { + stable_id: String, + hardware_id: String, + com_port: String, + }, + Reassign { + stable_id: String, + instance_id: String, + from: Option, + to: String, + }, + Retain { + stable_id: String, + instance_id: String, + com_port: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ProvisionPlan { + desired: Vec, + actions: Vec, + conflicts: Vec, +} + +impl ProvisionPlan { + pub(crate) fn is_applicable(&self) -> bool { + self.conflicts.is_empty() + } + + pub(crate) fn requires_changes(&self) -> bool { + self.actions + .iter() + .any(|action| !matches!(action, ProvisionAction::Retain { .. })) + } +} + +#[derive(Debug, Serialize)] +pub(crate) struct StatusReport { + platform: &'static str, + owned_endpoints: Vec, + com_claims: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ApplyReport { + changed: bool, + reboot_required: bool, + plan: ProvisionPlan, + status: StatusReport, +} + +#[derive(Debug, Serialize)] +pub(crate) struct RemoveReport { + removed: Vec, + reboot_required: bool, +} + +pub(crate) trait TextReport { + fn render_text(&self) -> String; +} + +impl TextReport for StatusReport { + fn render_text(&self) -> String { + let mut lines = vec![format!( + "CatHub virtual serial status: {} owned endpoint(s)", + self.owned_endpoints.len() + )]; + for endpoint in &self.owned_endpoints { + lines.push(format!( + " {}: {} ({}) [{}]", + endpoint.stable_id, + endpoint.com_port.as_deref().unwrap_or("unassigned"), + endpoint.instance_id, + endpoint.display_name + )); + } + if self.owned_endpoints.is_empty() { + lines.push(" No CatHub-owned devices are installed.".to_string()); + } + lines.push(format!( + "COM Name Arbiter/PnP claims: {}", + self.com_claims.len() + )); + for claim in &self.com_claims { + lines.push(format!(" {}: {}", claim.com_port, claim.owner)); + } + lines.join("\n") + } +} + +impl TextReport for ProvisionPlan { + fn render_text(&self) -> String { + let mut lines = vec![format!( + "CatHub virtual serial plan: {} endpoint(s), {} conflict(s)", + self.desired.len(), + self.conflicts.len() + )]; + for action in &self.actions { + let line = match action { + ProvisionAction::Create { + stable_id, + hardware_id, + com_port, + } => format!(" CREATE {stable_id} as {com_port} ({hardware_id})"), + ProvisionAction::Reassign { + stable_id, + instance_id, + from, + to, + } => format!( + " REASSIGN {stable_id} from {} to {to} ({instance_id})", + from.as_deref().unwrap_or("unassigned") + ), + ProvisionAction::Retain { + stable_id, + instance_id, + com_port, + } => format!(" RETAIN {stable_id} as {com_port} ({instance_id})"), + }; + lines.push(line); + } + for conflict in &self.conflicts { + lines.push(format!( + " CONFLICT {} is owned by {}", + conflict.com_port, conflict.owner + )); + } + lines.join("\n") + } +} + +impl TextReport for ApplyReport { + fn render_text(&self) -> String { + format!( + "{}\nApplied: changed={}, reboot_required={}\n{}", + self.plan.render_text(), + self.changed, + self.reboot_required, + self.status.render_text() + ) + } +} + +impl TextReport for RemoveReport { + fn render_text(&self) -> String { + let mut lines = vec![format!( + "Removed {} CatHub-owned endpoint(s); reboot_required={}", + self.removed.len(), + self.reboot_required + )]; + for endpoint in &self.removed { + lines.push(format!( + " {} ({})", + endpoint.stable_id, endpoint.instance_id + )); + } + lines.join("\n") + } +} + +pub(crate) fn status() -> Result { + let snapshot = platform::snapshot()?; + Ok(StatusReport { + platform: std::env::consts::OS, + owned_endpoints: snapshot.owned, + com_claims: snapshot.claims, + }) +} + +pub(crate) fn plan(config: &Config) -> Result { + let desired = desired_endpoints(config)?; + plan_from_snapshot(desired, &platform::snapshot()?) +} + +pub(crate) fn apply(config: &Config, inf_path: &Path) -> Result { + let inf_path = canonical_inf_path(inf_path)?; + let desired = desired_endpoints(config)?; + let before = platform::snapshot()?; + let plan = plan_from_snapshot(desired, &before)?; + if !plan.is_applicable() { + return Err(format_conflicts(&plan.conflicts)); + } + let changed = plan.requires_changes(); + let reboot_required = platform::apply(&plan, &inf_path)?; + let after = platform::snapshot()?; + let verification = plan_from_snapshot(plan.desired.clone(), &after)?; + if !verification.is_applicable() || verification.requires_changes() { + return Err("provisioning completed but the resulting PnP/COM state does not match the requested plan".to_string()); + } + Ok(ApplyReport { + changed, + reboot_required, + plan, + status: StatusReport { + platform: std::env::consts::OS, + owned_endpoints: after.owned, + com_claims: after.claims, + }, + }) +} + +pub(crate) fn remove(stable_ids: &[String]) -> Result { + let requested = stable_ids + .iter() + .map(|value| value.trim().to_ascii_lowercase()) + .collect::>(); + for stable_id in &requested { + definition(stable_id) + .ok_or_else(|| format!("unknown CatHub virtual endpoint `{stable_id}`"))?; + } + let snapshot = platform::snapshot()?; + let removed = snapshot + .owned + .into_iter() + .filter(|endpoint| requested.is_empty() || requested.contains(&endpoint.stable_id)) + .collect::>(); + let reboot_required = platform::remove(&removed)?; + Ok(RemoveReport { + removed, + reboot_required, + }) +} + +fn canonical_inf_path(path: &Path) -> Result { + let path = path + .canonicalize() + .map_err(|error| format!("resolving driver INF `{}`: {error}", path.display()))?; + if !path.is_file() + || path + .extension() + .is_none_or(|extension| !extension.eq_ignore_ascii_case("inf")) + { + return Err(format!( + "driver path must name an existing .inf file: {}", + path.display() + )); + } + Ok(path) +} + +fn desired_endpoints(config: &Config) -> Result, String> { + let mut desired = Vec::new(); + for endpoint in &config.serial_endpoint { + if let Some(stable_id) = endpoint.virtual_endpoint.as_deref() { + desired.push(desired_endpoint( + stable_id, + EndpointKind::Cat, + &endpoint.name, + endpoint.application_transport.as_deref(), + )?); + } + } + for endpoint in &config.winkeyer_endpoint { + if let Some(stable_id) = endpoint.virtual_endpoint.as_deref() { + desired.push(desired_endpoint( + stable_id, + EndpointKind::Winkeyer, + &endpoint.name, + endpoint.application_transport.as_deref(), + )?); + } + } + let mut stable_ids = BTreeSet::new(); + let mut ports = BTreeSet::new(); + for endpoint in &desired { + if !stable_ids.insert(endpoint.stable_id.to_ascii_lowercase()) { + return Err(format!( + "managed virtual endpoint `{}` is configured more than once", + endpoint.stable_id + )); + } + if !ports.insert(endpoint.com_port.to_ascii_uppercase()) { + return Err(format!( + "managed virtual COM port `{}` is configured more than once", + endpoint.com_port + )); + } + } + Ok(desired) +} + +fn desired_endpoint( + stable_id: &str, + kind: EndpointKind, + configured_name: &str, + requested_port: Option<&str>, +) -> Result { + let stable_id = stable_id.trim().to_ascii_lowercase(); + let definition = definition(&stable_id) + .ok_or_else(|| format!("unknown CatHub virtual endpoint `{stable_id}`"))?; + if definition.kind != kind { + return Err(format!( + "virtual endpoint `{stable_id}` is a {:?} endpoint and cannot back this {:?} configuration", + definition.kind, kind + )); + } + let com_port = normalize_com_port(requested_port.unwrap_or(definition.default_port))?; + Ok(DesiredEndpoint { + stable_id, + kind, + hardware_id: definition.hardware_id.to_string(), + display_name: definition.display_name.to_string(), + com_port, + configured_name: configured_name.to_string(), + }) +} + +fn definition(stable_id: &str) -> Option<&'static EndpointDefinition> { + ENDPOINTS + .iter() + .find(|endpoint| endpoint.stable_id.eq_ignore_ascii_case(stable_id)) +} + +fn definition_for_hardware_id(hardware_id: &str) -> Option<&'static EndpointDefinition> { + ENDPOINTS + .iter() + .find(|endpoint| endpoint.hardware_id.eq_ignore_ascii_case(hardware_id)) +} + +fn normalize_com_port(value: &str) -> Result { + let value = value.trim().to_ascii_uppercase(); + let number = value + .strip_prefix("COM") + .and_then(|number| number.parse::().ok()) + .filter(|number| (1..=4096).contains(number)) + .ok_or_else(|| format!("`{value}` is not a valid COM1 through COM4096 name"))?; + Ok(format!("COM{number}")) +} + +fn plan_from_snapshot( + mut desired: Vec, + snapshot: &SystemSnapshot, +) -> Result { + desired.sort_by(|left, right| left.stable_id.cmp(&right.stable_id)); + let installed = snapshot + .owned + .iter() + .map(|endpoint| (endpoint.stable_id.to_ascii_lowercase(), endpoint)) + .collect::>(); + let desired_ids = desired + .iter() + .map(|endpoint| endpoint.stable_id.as_str()) + .collect::>(); + let claims = snapshot + .claims + .iter() + .map(|claim| (claim.com_port.to_ascii_uppercase(), claim)) + .collect::>(); + let mut actions = Vec::new(); + let mut conflicts = Vec::new(); + + for endpoint in &desired { + let installed_endpoint = installed.get(&endpoint.stable_id); + if let Some(claim) = claims.get(&endpoint.com_port) { + let claim_belongs_to_endpoint = installed_endpoint.is_some_and(|installed| { + installed.instance_id.eq_ignore_ascii_case(&claim.owner) + || installed + .com_port + .as_ref() + .is_some_and(|port| port.eq_ignore_ascii_case(&claim.com_port)) + }); + if !claim_belongs_to_endpoint { + conflicts.push((*claim).clone()); + } + } + match installed_endpoint { + None => actions.push(ProvisionAction::Create { + stable_id: endpoint.stable_id.clone(), + hardware_id: endpoint.hardware_id.clone(), + com_port: endpoint.com_port.clone(), + }), + Some(installed_endpoint) + if installed_endpoint + .com_port + .as_ref() + .is_some_and(|port| port.eq_ignore_ascii_case(&endpoint.com_port)) => + { + actions.push(ProvisionAction::Retain { + stable_id: endpoint.stable_id.clone(), + instance_id: installed_endpoint.instance_id.clone(), + com_port: endpoint.com_port.clone(), + }); + } + Some(installed_endpoint) => actions.push(ProvisionAction::Reassign { + stable_id: endpoint.stable_id.clone(), + instance_id: installed_endpoint.instance_id.clone(), + from: installed_endpoint.com_port.clone(), + to: endpoint.com_port.clone(), + }), + } + } + conflicts.sort_by(|left, right| left.com_port.cmp(&right.com_port)); + conflicts.dedup_by(|left, right| left.com_port.eq_ignore_ascii_case(&right.com_port)); + + let duplicate_installed = snapshot.owned.iter().find(|endpoint| { + desired_ids.contains(endpoint.stable_id.as_str()) + && snapshot + .owned + .iter() + .filter(|candidate| candidate.stable_id == endpoint.stable_id) + .count() + > 1 + }); + if let Some(endpoint) = duplicate_installed { + return Err(format!( + "multiple CatHub-owned PnP devices advertise stable endpoint `{}`; remove the duplicates before applying", + endpoint.stable_id + )); + } + + Ok(ProvisionPlan { + desired, + actions, + conflicts, + }) +} + +fn format_conflicts(conflicts: &[PortClaim]) -> String { + let details = conflicts + .iter() + .map(|claim| format!("{} ({})", claim.com_port, claim.owner)) + .collect::>() + .join(", "); + format!("requested COM ports are already claimed: {details}") +} + +#[cfg(windows)] +#[allow(unsafe_code)] +mod platform { + use std::collections::BTreeSet; + use std::ffi::c_void; + use std::io; + use std::mem::{size_of, zeroed}; + use std::path::Path; + use std::ptr::{null, null_mut}; + + use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{ + DiInstallDriverW, SetupDiCallClassInstaller, SetupDiCreateDeviceInfoList, + SetupDiCreateDeviceInfoW, SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInfo, + SetupDiGetClassDevsW, SetupDiGetDeviceInstanceIdW, SetupDiGetDeviceRegistryPropertyW, + SetupDiOpenDevRegKey, SetupDiOpenDeviceInfoW, SetupDiRemoveDevice, SetupDiRestartDevices, + SetupDiSetDeviceRegistryPropertyW, UpdateDriverForPlugAndPlayDevicesW, DICD_GENERATE_ID, + DICS_FLAG_GLOBAL, DIF_REGISTERDEVICE, DIGCF_ALLCLASSES, DIIRFLAG_FORCE_INF, DIREG_DEV, + GUID_DEVCLASS_PORTS, HDEVINFO, INSTALLFLAG_FORCE, SPDRP_DEVICEDESC, SPDRP_FRIENDLYNAME, + SPDRP_HARDWAREID, SP_DEVINFO_DATA, + }; + use windows_sys::Win32::Foundation::{ + GetLastError, ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, INVALID_HANDLE_VALUE, + }; + use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegOpenKeyExW, RegQueryValueExW, RegSetValueExW, HKEY, HKEY_LOCAL_MACHINE, + KEY_READ, KEY_SET_VALUE, REG_BINARY, REG_SZ, + }; + use windows_sys::Win32::UI::Shell::IsUserAnAdmin; + + use super::{ + definition_for_hardware_id, normalize_com_port, InstalledEndpoint, PortClaim, + ProvisionAction, ProvisionPlan, SystemSnapshot, + }; + + const COM_NAME_ARBITER: &str = r"SYSTEM\CurrentControlSet\Control\COM Name Arbiter"; + const COM_DATABASE_VALUE: &str = "ComDB"; + const PORT_NAME_VALUE: &str = "PortName"; + const MAX_COM_PORTS: usize = 4096; + + type HComDb = *mut c_void; + + #[link(name = "msports")] + unsafe extern "system" { + fn ComDBOpen(database: *mut HComDb) -> i32; + fn ComDBClose(database: HComDb) -> i32; + fn ComDBClaimPort( + database: HComDb, + com_number: u32, + force_claim: i32, + forced: *mut i32, + ) -> i32; + fn ComDBReleasePort(database: HComDb, com_number: u32) -> i32; + } + + struct DeviceInfoSet(HDEVINFO); + + impl DeviceInfoSet { + fn all() -> Result { + // SAFETY: Null class/enumerator selects all device classes; no window owner is needed. + let handle = + unsafe { SetupDiGetClassDevsW(null(), null(), null_mut(), DIGCF_ALLCLASSES) }; + if handle == INVALID_HANDLE_VALUE as HDEVINFO { + Err(last_error("enumerating Windows PnP devices")) + } else { + Ok(Self(handle)) + } + } + + fn ports() -> Result { + // SAFETY: The class GUID is static and no window owner is needed. + let handle = unsafe { SetupDiCreateDeviceInfoList(&GUID_DEVCLASS_PORTS, null_mut()) }; + if handle == INVALID_HANDLE_VALUE as HDEVINFO { + Err(last_error("creating a Ports-class device information set")) + } else { + Ok(Self(handle)) + } + } + } + + impl Drop for DeviceInfoSet { + fn drop(&mut self) { + // SAFETY: This wrapper exclusively owns the valid SetupAPI information-set handle. + unsafe { SetupDiDestroyDeviceInfoList(self.0) }; + } + } + + struct ComDatabase(HComDb); + + impl ComDatabase { + fn open() -> Result { + let mut database = null_mut(); + // SAFETY: The output pointer is valid and receives an opaque handle on success. + let result = unsafe { ComDBOpen(&raw mut database) }; + if result == 0 { + Ok(Self(database)) + } else { + Err(format!( + "opening the COM Name Arbiter database failed with Win32 error {result}" + )) + } + } + + fn claim(&self, com_port: &str) -> Result<(), String> { + let number = com_number(com_port)?; + let mut forced = 0; + // SAFETY: The database handle is open and the output pointer is valid. + let result = unsafe { ComDBClaimPort(self.0, number, 0, &raw mut forced) }; + if result == 0 { + Ok(()) + } else { + Err(format!( + "claiming {com_port} in the COM Name Arbiter failed with Win32 error {result}" + )) + } + } + + fn release(&self, com_port: &str) -> Result<(), String> { + let number = com_number(com_port)?; + // SAFETY: The database handle is open and the port number is within the documented range. + let result = unsafe { ComDBReleasePort(self.0, number) }; + if result == 0 { + Ok(()) + } else { + Err(format!( + "releasing {com_port} in the COM Name Arbiter failed with Win32 error {result}" + )) + } + } + } + + impl Drop for ComDatabase { + fn drop(&mut self) { + // SAFETY: This wrapper exclusively owns the open COM database handle. + unsafe { ComDBClose(self.0) }; + } + } + + pub(super) fn snapshot() -> Result { + let set = DeviceInfoSet::all()?; + let mut owned = Vec::new(); + let mut claims = Vec::new(); + let mut live_ports = BTreeSet::new(); + let mut index = 0; + loop { + let mut data = device_info_data(); + // SAFETY: The information set is valid and the initialized output remains live. + if unsafe { SetupDiEnumDeviceInfo(set.0, index, &raw mut data) } == 0 { + // SAFETY: GetLastError immediately follows the failed SetupAPI call. + let error = unsafe { GetLastError() }; + if error == ERROR_NO_MORE_ITEMS { + break; + } + return Err(last_error("enumerating a Windows PnP device")); + } + index += 1; + let instance_id = device_instance_id(set.0, &data)?; + let port = + device_port_name(set.0, &data).and_then(|port| normalize_com_port(&port).ok()); + let hardware_ids = device_property_strings(set.0, &data, SPDRP_HARDWAREID); + let definition = hardware_ids + .iter() + .find_map(|hardware_id| definition_for_hardware_id(hardware_id)); + if let Some(port) = &port { + live_ports.insert(port.clone()); + claims.push(PortClaim { + com_port: port.clone(), + owner: instance_id.clone(), + cathub_owned: definition.is_some(), + }); + } + if let Some(definition) = definition { + let display_name = device_property_strings(set.0, &data, SPDRP_FRIENDLYNAME) + .into_iter() + .next() + .or_else(|| { + device_property_strings(set.0, &data, SPDRP_DEVICEDESC) + .into_iter() + .next() + }) + .unwrap_or_else(|| definition.display_name.to_string()); + owned.push(InstalledEndpoint { + stable_id: definition.stable_id.to_string(), + kind: definition.kind, + hardware_id: definition.hardware_id.to_string(), + instance_id, + display_name, + com_port: port, + }); + } + } + for port in arbiter_claims()? { + if live_ports.insert(port.clone()) { + claims.push(PortClaim { + com_port: port, + owner: "COM Name Arbiter reservation".to_string(), + cathub_owned: false, + }); + } + } + owned.sort_by(|left, right| left.stable_id.cmp(&right.stable_id)); + claims.sort_by(|left, right| com_number(&left.com_port).cmp(&com_number(&right.com_port))); + Ok(SystemSnapshot { owned, claims }) + } + + pub(super) fn apply(plan: &ProvisionPlan, inf_path: &Path) -> Result { + require_administrator()?; + let inf = wide(&inf_path.to_string_lossy()); + let mut reboot_required = 0; + // SAFETY: The canonical path is NUL-terminated and all outputs are valid for the call. + if unsafe { + DiInstallDriverW( + null_mut(), + inf.as_ptr(), + DIIRFLAG_FORCE_INF, + &raw mut reboot_required, + ) + } == 0 + { + return Err(last_error(&format!( + "staging CatHub driver `{}`", + inf_path.display() + ))); + } + let database = ComDatabase::open()?; + for action in &plan.actions { + match action { + ProvisionAction::Create { + stable_id, + hardware_id, + com_port, + } => { + let definition = definition_for_hardware_id(hardware_id).ok_or_else(|| { + format!("refusing to create non-CatHub hardware ID `{hardware_id}`") + })?; + if definition.stable_id != stable_id { + return Err(format!("stable endpoint `{stable_id}` does not own hardware ID `{hardware_id}`")); + } + database.claim(com_port)?; + if let Err(error) = create_device( + definition.hardware_id, + definition.display_name, + com_port, + inf_path, + &mut reboot_required, + ) { + let _ = database.release(com_port); + return Err(error); + } + } + ProvisionAction::Reassign { + instance_id, + from, + to, + .. + } => { + database.claim(to)?; + if let Err(error) = set_existing_port(instance_id, to) { + let _ = database.release(to); + return Err(error); + } + if let Some(from) = from { + database.release(from)?; + } + } + ProvisionAction::Retain { .. } => {} + } + } + Ok(reboot_required != 0) + } + + pub(super) fn remove(endpoints: &[InstalledEndpoint]) -> Result { + require_administrator()?; + let database = ComDatabase::open()?; + let mut reboot_required = false; + for endpoint in endpoints { + let definition = + definition_for_hardware_id(&endpoint.hardware_id).ok_or_else(|| { + format!( + "refusing to remove non-CatHub device `{}`", + endpoint.instance_id + ) + })?; + if definition.stable_id != endpoint.stable_id { + return Err(format!( + "device `{}` has inconsistent CatHub ownership metadata", + endpoint.instance_id + )); + } + with_device(&endpoint.instance_id, |set, data| { + // SAFETY: The device data belongs to the live information set and was found by exact instance ID. + if unsafe { SetupDiRemoveDevice(set, data) } == 0 { + return Err(last_error(&format!( + "removing PnP device `{}`", + endpoint.instance_id + ))); + } + Ok(()) + })?; + if let Some(port) = &endpoint.com_port { + database.release(port)?; + } + reboot_required |= false; + } + Ok(reboot_required) + } + + fn create_device( + hardware_id: &str, + display_name: &str, + com_port: &str, + inf_path: &Path, + reboot_required: &mut i32, + ) -> Result<(), String> { + let set = DeviceInfoSet::ports()?; + let class_name = wide("Ports"); + let description = wide(display_name); + let mut data = device_info_data(); + // SAFETY: All strings and structures remain valid throughout this synchronous call. + if unsafe { + SetupDiCreateDeviceInfoW( + set.0, + class_name.as_ptr(), + &GUID_DEVCLASS_PORTS, + description.as_ptr(), + null_mut(), + DICD_GENERATE_ID, + &raw mut data, + ) + } == 0 + { + return Err(last_error(&format!( + "creating CatHub device `{hardware_id}`" + ))); + } + let mut hardware_id_value = wide(hardware_id); + hardware_id_value.push(0); + let byte_len = byte_len(&hardware_id_value)?; + // SAFETY: The buffer is a valid NUL-terminated REG_MULTI_SZ and the device data belongs to the set. + if unsafe { + SetupDiSetDeviceRegistryPropertyW( + set.0, + &raw mut data, + SPDRP_HARDWAREID, + hardware_id_value.as_ptr().cast(), + byte_len, + ) + } == 0 + { + return Err(last_error(&format!("setting hardware ID `{hardware_id}`"))); + } + // SAFETY: Registering the newly created device uses its owning information set. + if unsafe { SetupDiCallClassInstaller(DIF_REGISTERDEVICE, set.0, &raw const data) } == 0 { + return Err(last_error(&format!( + "registering CatHub device `{hardware_id}`" + ))); + } + let result = (|| { + set_port_name(set.0, &data, com_port)?; + let hardware_id = wide(hardware_id); + let inf = wide(&inf_path.to_string_lossy()); + // SAFETY: Both input strings are NUL-terminated and the reboot output is valid. + if unsafe { + UpdateDriverForPlugAndPlayDevicesW( + null_mut(), + hardware_id.as_ptr(), + inf.as_ptr(), + INSTALLFLAG_FORCE, + reboot_required, + ) + } == 0 + { + return Err(last_error(&format!( + "installing the CatHub driver for `{hardware_id:?}`" + ))); + } + Ok(()) + })(); + if result.is_err() { + // SAFETY: Best-effort rollback targets only the just-created CatHub device. + unsafe { SetupDiRemoveDevice(set.0, &raw mut data) }; + } + result + } + + fn set_existing_port(instance_id: &str, com_port: &str) -> Result<(), String> { + with_device(instance_id, |set, data| { + set_port_name(set, data, com_port)?; + // SAFETY: The data belongs to the live set and identifies the exact CatHub instance. + if unsafe { SetupDiRestartDevices(set, data) } == 0 { + return Err(last_error(&format!( + "restarting CatHub device `{instance_id}`" + ))); + } + Ok(()) + }) + } + + fn with_device( + instance_id: &str, + operation: impl FnOnce(HDEVINFO, &mut SP_DEVINFO_DATA) -> Result<(), String>, + ) -> Result<(), String> { + let set = DeviceInfoSet::all()?; + let instance = wide(instance_id); + let mut data = device_info_data(); + // SAFETY: The exact instance ID is NUL-terminated and output storage is initialized. + if unsafe { SetupDiOpenDeviceInfoW(set.0, instance.as_ptr(), null_mut(), 0, &raw mut data) } + == 0 + { + return Err(last_error(&format!("opening PnP device `{instance_id}`"))); + } + operation(set.0, &mut data) + } + + fn set_port_name(set: HDEVINFO, data: &SP_DEVINFO_DATA, com_port: &str) -> Result<(), String> { + // SAFETY: The device data belongs to the live information set. + let key = unsafe { + SetupDiOpenDevRegKey(set, data, DICS_FLAG_GLOBAL, 0, DIREG_DEV, KEY_SET_VALUE) + }; + if key as isize == INVALID_HANDLE_VALUE as isize { + return Err(last_error(&format!( + "opening the device registry key for {com_port}" + ))); + } + let name = wide(PORT_NAME_VALUE); + let value = wide(com_port); + let result = byte_len(&value).and_then(|length| { + // SAFETY: The key is open and both strings remain valid through the call. + let status = unsafe { + RegSetValueExW(key, name.as_ptr(), 0, REG_SZ, value.as_ptr().cast(), length) + }; + if status == ERROR_SUCCESS { + Ok(()) + } else { + Err(format!( + "setting PortName={com_port} failed with Win32 error {status}" + )) + } + }); + // SAFETY: This function owns the registry handle returned above. + unsafe { RegCloseKey(key) }; + result + } + + fn device_port_name(set: HDEVINFO, data: &SP_DEVINFO_DATA) -> Option { + // SAFETY: The device data belongs to the live information set. + let key = + unsafe { SetupDiOpenDevRegKey(set, data, DICS_FLAG_GLOBAL, 0, DIREG_DEV, KEY_READ) }; + if key as isize == INVALID_HANDLE_VALUE as isize { + return None; + } + let value = query_registry_string(key, PORT_NAME_VALUE); + // SAFETY: This function owns the registry handle returned above. + unsafe { RegCloseKey(key) }; + value + } + + fn query_registry_string(key: HKEY, name: &str) -> Option { + let name = wide(name); + let mut kind = 0; + let mut bytes = 0; + // SAFETY: The key is open and size output is valid; null data performs a size query. + if unsafe { + RegQueryValueExW( + key, + name.as_ptr(), + null(), + &raw mut kind, + null_mut(), + &raw mut bytes, + ) + } != ERROR_SUCCESS + || kind != REG_SZ + || bytes == 0 + { + return None; + } + let mut buffer = vec![0_u16; usize::try_from(bytes).ok()?.div_ceil(2)]; + // SAFETY: The byte-sized buffer and all outputs remain valid for the query. + if unsafe { + RegQueryValueExW( + key, + name.as_ptr(), + null(), + &raw mut kind, + buffer.as_mut_ptr().cast(), + &raw mut bytes, + ) + } != ERROR_SUCCESS + { + return None; + } + Some( + String::from_utf16_lossy(&buffer) + .trim_matches('\0') + .trim() + .to_string(), + ) + } + + fn arbiter_claims() -> Result, String> { + let path = wide(COM_NAME_ARBITER); + let mut key = null_mut(); + // SAFETY: The path is NUL-terminated and output storage is valid. + let status = + unsafe { RegOpenKeyExW(HKEY_LOCAL_MACHINE, path.as_ptr(), 0, KEY_READ, &raw mut key) }; + if status != ERROR_SUCCESS { + return Err(format!( + "opening the COM Name Arbiter failed with Win32 error {status}" + )); + } + let name = wide(COM_DATABASE_VALUE); + let mut kind = 0; + let mut bytes = 0; + // SAFETY: The key is open and null data requests the required byte count. + let size_status = unsafe { + RegQueryValueExW( + key, + name.as_ptr(), + null(), + &raw mut kind, + null_mut(), + &raw mut bytes, + ) + }; + if size_status != ERROR_SUCCESS || kind != REG_BINARY { + // SAFETY: This function owns the open key. + unsafe { RegCloseKey(key) }; + return Err(format!( + "reading the COM Name Arbiter database failed with Win32 error {size_status}" + )); + } + let mut bitmap = + vec![0_u8; usize::try_from(bytes).map_err(|_| "COM database is too large")?]; + // SAFETY: The allocated byte buffer matches the requested registry value size. + let query_status = unsafe { + RegQueryValueExW( + key, + name.as_ptr(), + null(), + &raw mut kind, + bitmap.as_mut_ptr(), + &raw mut bytes, + ) + }; + // SAFETY: This function owns the open key. + unsafe { RegCloseKey(key) }; + if query_status != ERROR_SUCCESS { + return Err(format!( + "reading the COM Name Arbiter database failed with Win32 error {query_status}" + )); + } + let mut claims = Vec::new(); + for number in 1..=MAX_COM_PORTS.min(bitmap.len() * 8) { + let bit = number - 1; + if bitmap + .get(bit / 8) + .is_some_and(|value| value & (1 << (bit % 8)) != 0) + { + claims.push(format!("COM{number}")); + } + } + Ok(claims) + } + + fn device_property_strings( + set: HDEVINFO, + data: &SP_DEVINFO_DATA, + property: u32, + ) -> Vec { + let mut kind = 0; + let mut bytes = 0; + // SAFETY: Null data performs a size query for a valid device and property. + unsafe { + SetupDiGetDeviceRegistryPropertyW( + set, + data, + property, + &raw mut kind, + null_mut(), + 0, + &raw mut bytes, + ); + } + if bytes == 0 { + return Vec::new(); + } + let Ok(units) = usize::try_from(bytes).map(|bytes| bytes.div_ceil(2)) else { + return Vec::new(); + }; + let mut buffer = vec![0_u16; units]; + // SAFETY: The byte-sized buffer matches the size reported by SetupAPI. + if unsafe { + SetupDiGetDeviceRegistryPropertyW( + set, + data, + property, + &raw mut kind, + buffer.as_mut_ptr().cast(), + bytes, + &raw mut bytes, + ) + } == 0 + { + return Vec::new(); + } + buffer + .split(|unit| *unit == 0) + .filter(|value| !value.is_empty()) + .map(String::from_utf16_lossy) + .collect() + } + + fn device_instance_id(set: HDEVINFO, data: &SP_DEVINFO_DATA) -> Result { + let mut required = 0; + // SAFETY: Null output requests the required UTF-16 unit count. + unsafe { SetupDiGetDeviceInstanceIdW(set, data, null_mut(), 0, &raw mut required) }; + if required == 0 { + return Err(last_error("querying a PnP device instance ID length")); + } + let mut buffer = + vec![0_u16; usize::try_from(required).map_err(|_| "PnP instance ID is too long")?]; + // SAFETY: The buffer has the exact unit count reported by SetupAPI. + if unsafe { + SetupDiGetDeviceInstanceIdW(set, data, buffer.as_mut_ptr(), required, &raw mut required) + } == 0 + { + return Err(last_error("reading a PnP device instance ID")); + } + Ok(String::from_utf16_lossy(&buffer) + .trim_matches('\0') + .to_string()) + } + + fn require_administrator() -> Result<(), String> { + // SAFETY: This parameterless shell helper checks membership in the local Administrators group. + if unsafe { IsUserAnAdmin() } == 0 { + Err("virtual serial apply/remove requires an elevated Administrator shell".to_string()) + } else { + Ok(()) + } + } + + fn device_info_data() -> SP_DEVINFO_DATA { + // SAFETY: The Windows structure is plain-old-data and requires cbSize initialization. + let mut data: SP_DEVINFO_DATA = unsafe { zeroed() }; + data.cbSize = u32::try_from(size_of::()).unwrap_or(u32::MAX); + data + } + + fn wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() + } + + fn byte_len(value: &[u16]) -> Result { + u32::try_from(value.len().saturating_mul(size_of::())) + .map_err(|_| "UTF-16 registry value is too large".to_string()) + } + + fn com_number(com_port: &str) -> Result { + normalize_com_port(com_port)? + .strip_prefix("COM") + .and_then(|number| number.parse().ok()) + .ok_or_else(|| format!("invalid COM port `{com_port}`")) + } + + fn last_error(context: &str) -> String { + format!("{context}: {}", io::Error::last_os_error()) + } +} + +#[cfg(not(windows))] +mod platform { + use super::{InstalledEndpoint, ProvisionPlan, SystemSnapshot}; + use std::path::Path; + + pub(super) fn snapshot() -> Result { + Err("CatHub virtual serial provisioning requires Windows".to_string()) + } + + pub(super) fn apply(_plan: &ProvisionPlan, _inf_path: &Path) -> Result { + Err("CatHub virtual serial provisioning requires Windows".to_string()) + } + + pub(super) fn remove(_endpoints: &[InstalledEndpoint]) -> Result { + Err("CatHub virtual serial provisioning requires Windows".to_string()) + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::*; + + fn config(body: &str) -> Config { + Config::parse(body).expect("config") + } + + #[test] + fn derives_the_five_production_endpoint_identities() { + let config = config( + r#" +[radio] +backend = "loopback" + +[[serial_endpoint]] +name = "hdsdr" +virtual_endpoint = "hdsdr-cat" +application_transport = "COM11" +dialect = "ts2000" + +[[serial_endpoint]] +name = "n1mm" +virtual_endpoint = "n1mm-cat" +application_transport = "COM21" +dialect = "ts590" + +[[serial_endpoint]] +name = "arcp" +virtual_endpoint = "arcp590-cat" +application_transport = "COM31" +dialect = "ts590-transparent" + +[winkeyer] +port = "COM3" + +[[winkeyer_endpoint]] +name = "n1mm-winkeyer" +virtual_endpoint = "n1mm-winkeyer" +application_transport = "COM41" + +[[winkeyer_endpoint]] +name = "wktools" +virtual_endpoint = "wktools" +application_transport = "COM43" +"#, + ); + let desired = desired_endpoints(&config).expect("desired"); + assert_eq!(desired.len(), 5); + assert_eq!( + desired.first().unwrap().hardware_id, + r"ROOT\CATHUB_HDSDR_CAT" + ); + assert_eq!(desired.get(4).unwrap().com_port, "COM43"); + } + + #[test] + fn detects_non_cathub_com_claims() { + let config = config( + r#" +[radio] +backend = "loopback" +[[serial_endpoint]] +name = "n1mm" +virtual_endpoint = "n1mm-cat" +application_transport = "com21" +dialect = "ts590" +"#, + ); + let snapshot = SystemSnapshot { + owned: Vec::new(), + claims: vec![PortClaim { + com_port: "COM21".to_string(), + owner: "USB\\VID_1234".to_string(), + cathub_owned: false, + }], + }; + let plan = plan_from_snapshot(desired_endpoints(&config).unwrap(), &snapshot).unwrap(); + assert_eq!(plan.conflicts.len(), 1); + assert!(matches!( + plan.actions.first(), + Some(ProvisionAction::Create { .. }) + )); + } + + #[test] + fn retained_endpoint_is_idempotent() { + let config = config( + r#" +[radio] +backend = "loopback" +[[serial_endpoint]] +name = "n1mm" +virtual_endpoint = "n1mm-cat" +application_transport = "COM21" +dialect = "ts590" +"#, + ); + let snapshot = SystemSnapshot { + owned: vec![InstalledEndpoint { + stable_id: "n1mm-cat".to_string(), + kind: EndpointKind::Cat, + hardware_id: r"ROOT\CATHUB_N1MM_CAT".to_string(), + instance_id: r"ROOT\CATHUB_N1MM_CAT\0000".to_string(), + display_name: "CatHub N1MM CAT Port".to_string(), + com_port: Some("COM21".to_string()), + }], + claims: vec![PortClaim { + com_port: "COM21".to_string(), + owner: r"ROOT\CATHUB_N1MM_CAT\0000".to_string(), + cathub_owned: true, + }], + }; + let plan = plan_from_snapshot(desired_endpoints(&config).unwrap(), &snapshot).unwrap(); + assert!(plan.is_applicable()); + assert!(!plan.requires_changes()); + } + + #[test] + fn rejects_kind_mismatch_and_invalid_com_name() { + assert!(desired_endpoint("wktools", EndpointKind::Cat, "bad", None).is_err()); + assert!(normalize_com_port("COM0").is_err()); + assert!(normalize_com_port("LPT1").is_err()); + assert_eq!(normalize_com_port("com0042").unwrap(), "COM42"); + } +} diff --git a/docs/integration/windows-virtual-serial.md b/docs/integration/windows-virtual-serial.md new file mode 100644 index 0000000..1d55f60 --- /dev/null +++ b/docs/integration/windows-virtual-serial.md @@ -0,0 +1,87 @@ +# CatHub-owned Windows virtual serial endpoints + +CatHub's UMDF transport replaces each com0com null-modem pair with one application-facing COM +port. `cathub.exe` attaches through a separate private device interface, so the daemon does not +consume another COM number. + +| Stable endpoint | Kind | Default application port | Intended client | +|---|---|---:|---| +| `hdsdr-cat` | CAT | COM11 | HDSDR through OmniRig | +| `n1mm-cat` | CAT | COM21 | N1MM radio CAT | +| `arcp590-cat` | CAT | COM31 | ARCP-590 | +| `n1mm-winkeyer` | WinKeyer | COM41 | N1MM WinKeyer | +| `wktools` | WinKeyer | COM43 | WKTools maintenance | + +The compatibility alias `cathub-default` is also available for the isolated test harness. A +configuration selects a managed endpoint explicitly: + +```toml +[[serial_endpoint]] +name = "n1mm" +virtual_endpoint = "n1mm-cat" +application_transport = "COM21" +dialect = "ts590" +single_vfo = true +perms = ["read", "write", "ptt"] +``` + +`application_transport` records and provisions the public COM name. When omitted, CatHub uses the +endpoint's default from the table above. + +## Inspect and plan + +Status is read-only and does not require elevation: + +```powershell +cathub virtual-serial status +cathub virtual-serial status --format json +``` + +Plan loads the selected CatHub configuration and compares it with PnP devices and the COM Name +Arbiter. It reports create, retain, or reassign actions and blocks any port already owned by a +physical device, com0com, another virtual driver, or a stale arbiter reservation. + +```powershell +cathub --config C:\ProgramData\CatHub\cathub.toml virtual-serial plan +``` + +The planner never removes or repurposes a non-CatHub device. Migrate or remove old com0com pairs +explicitly before asking CatHub to reuse their application-side COM numbers. + +## Apply and remove + +Run device-changing operations from an elevated Administrator terminal. `apply` requires the INF +from a complete, signed CatHub driver package; keep the INF, catalog, and UMDF DLL together. + +```powershell +cathub --config C:\ProgramData\CatHub\cathub.toml virtual-serial apply ` + --inf C:\ProgramData\CatHub\driver\cathub_virtual_serial_umdf.inf +``` + +Apply stages the package, creates only hardware IDs from CatHub's fixed allow-list, claims the +requested COM numbers, installs or restarts the device, and verifies the resulting PnP state. It is +idempotent: a second successful run reports retained endpoints and makes no changes. + +Remove every CatHub-owned endpoint, or select stable IDs individually: + +```powershell +cathub virtual-serial remove +cathub virtual-serial remove --endpoint n1mm-cat --endpoint n1mm-winkeyer +``` + +Removal checks the same compiled ownership allow-list and cannot target physical ports or com0com +devices. It removes endpoint device instances but leaves the driver package staged so a later +repair/apply does not depend on network access. + +## Development package + +The repository packaging command creates an ephemeral development certificate and signs the +catalog without installing that certificate on the build workstation: + +```powershell +.\scripts\Test-UmdfPoc.ps1 -Action Package +``` + +The resulting package is suitable only for the isolated VM procedure in +`scripts/Test-UmdfEndToEnd.ps1`. Production distribution still requires the approved public +catalog-signing path and clean-system Secure Boot/Memory Integrity acceptance evidence. diff --git a/scripts/Test-UmdfEndToEnd.ps1 b/scripts/Test-UmdfEndToEnd.ps1 index f05e60a..0e038e1 100644 --- a/scripts/Test-UmdfEndToEnd.ps1 +++ b/scripts/Test-UmdfEndToEnd.ps1 @@ -5,7 +5,6 @@ param( [string]$DriverPackage = (Join-Path $PSScriptRoot 'driver'), [string]$CatHubExe = (Join-Path $PSScriptRoot 'cathub.exe'), - [string]$DevConExe = (Join-Path $PSScriptRoot 'devcon.exe'), [string]$ResultsPath = (Join-Path $PSScriptRoot 'cathub-umdf-e2e.json') ) @@ -77,7 +76,7 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra $infPath = Join-Path $DriverPackage 'cathub_virtual_serial_umdf.inf' $certificatePath = Join-Path $DriverPackage 'cathub_umdf_test.cer' -foreach ($path in @($infPath, $certificatePath, $CatHubExe, $DevConExe)) { +foreach ($path in @($infPath, $certificatePath, $CatHubExe)) { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Required test input is missing: $path" } @@ -97,6 +96,7 @@ model = "TS-590SG" [[serial_endpoint]] name = "umdf-e2e" virtual_endpoint = "cathub-default" +application_transport = "COM91" dialect = "ts590" perms = ["read", "frequency_write", "write", "ptt", "config_write"] '@ | Set-Content -LiteralPath $configPath -Encoding utf8 @@ -106,7 +106,12 @@ $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]: ) Invoke-Checked certutil @('-f', '-addstore', 'Root', $certificatePath) Invoke-Checked certutil @('-f', '-addstore', 'TrustedPublisher', $certificatePath) -Invoke-Checked $DevConExe @('install', $infPath, 'root\CATHUB_VIRTUAL_SERIAL') +Invoke-Checked $CatHubExe @( + '--config', $configPath, + 'virtual-serial', 'apply', + '--inf', $infPath, + '--format', 'json' +) $portName = Find-CatHubPort $process = $null From 47bf18968b0357f94bc5e590171ff412b3f543ab Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 20:43:53 -0700 Subject: [PATCH 13/39] Restrict the UMDF daemon channel to its owner --- crates/cathub/Cargo.toml | 3 + crates/cathub/src/managed_virtual_serial.rs | 8 +- .../cathub/src/virtual_serial_provisioning.rs | 250 +++++++++++++++++- docs/integration/windows-virtual-serial.md | 6 + drivers/cathub-virtual-serial-umdf/Cargo.lock | 1 + drivers/cathub-virtual-serial-umdf/Cargo.toml | 1 + .../cathub_virtual_serial_umdf.inx | 1 + .../cathub-virtual-serial-umdf/src/interop.rs | 132 ++++++++- 8 files changed, 383 insertions(+), 19 deletions(-) diff --git a/crates/cathub/Cargo.toml b/crates/cathub/Cargo.toml index c6b81a3..eeb88ed 100644 --- a/crates/cathub/Cargo.toml +++ b/crates/cathub/Cargo.toml @@ -39,7 +39,10 @@ tracing-subscriber = { workspace = true } windows-sys = { version = "0.59", features = [ "Win32_Devices_DeviceAndDriverInstallation", "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", "Win32_System_Registry", + "Win32_System_Threading", "Win32_UI_Shell", ] } diff --git a/crates/cathub/src/managed_virtual_serial.rs b/crates/cathub/src/managed_virtual_serial.rs index 1859566..e54f784 100644 --- a/crates/cathub/src/managed_virtual_serial.rs +++ b/crates/cathub/src/managed_virtual_serial.rs @@ -124,6 +124,7 @@ mod platform { use std::fs::{File, OpenOptions}; use std::io::{self, Read, Write}; use std::mem::{offset_of, size_of}; + use std::os::windows::fs::OpenOptionsExt; use std::ptr::{null, null_mut}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -141,6 +142,7 @@ mod platform { use windows_sys::Win32::Foundation::{ GetLastError, ERROR_INSUFFICIENT_BUFFER, ERROR_NO_MORE_ITEMS, INVALID_HANDLE_VALUE, }; + use windows_sys::Win32::Storage::FileSystem::{SECURITY_IMPERSONATION, SECURITY_SQOS_PRESENT}; const PRIVATE_INTERFACE: GUID = GUID { data1: 0x0084_BDDE, @@ -189,7 +191,11 @@ mod platform { } fn connect_path(path: &str, stable_id: &str, expected_kind: u16) -> io::Result { - let mut file = OpenOptions::new().read(true).write(true).open(path)?; + let mut file = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(SECURITY_SQOS_PRESENT | SECURITY_IMPERSONATION) + .open(path)?; let mut protocol = DaemonProtocol::new(); write_frame( diff --git a/crates/cathub/src/virtual_serial_provisioning.rs b/crates/cathub/src/virtual_serial_provisioning.rs index c187ace..ca3475d 100644 --- a/crates/cathub/src/virtual_serial_provisioning.rs +++ b/crates/cathub/src/virtual_serial_provisioning.rs @@ -87,6 +87,7 @@ pub(crate) struct InstalledEndpoint { instance_id: String, display_name: String, com_port: Option, + authorized_for_current_user: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -116,6 +117,11 @@ pub(crate) enum ProvisionAction { from: Option, to: String, }, + Authorize { + stable_id: String, + instance_id: String, + com_port: String, + }, Retain { stable_id: String, instance_id: String, @@ -181,6 +187,9 @@ impl TextReport for StatusReport { endpoint.instance_id, endpoint.display_name )); + if !endpoint.authorized_for_current_user { + lines.push(" Private daemon access requires owner reconciliation.".to_string()); + } } if self.owned_endpoints.is_empty() { lines.push(" No CatHub-owned devices are installed.".to_string()); @@ -224,6 +233,11 @@ impl TextReport for ProvisionPlan { instance_id, com_port, } => format!(" RETAIN {stable_id} as {com_port} ({instance_id})"), + ProvisionAction::Authorize { + stable_id, + instance_id, + com_port, + } => format!(" AUTHORIZE {stable_id} on {com_port} ({instance_id})"), }; lines.push(line); } @@ -481,7 +495,8 @@ fn plan_from_snapshot( if installed_endpoint .com_port .as_ref() - .is_some_and(|port| port.eq_ignore_ascii_case(&endpoint.com_port)) => + .is_some_and(|port| port.eq_ignore_ascii_case(&endpoint.com_port)) + && installed_endpoint.authorized_for_current_user => { actions.push(ProvisionAction::Retain { stable_id: endpoint.stable_id.clone(), @@ -489,6 +504,18 @@ fn plan_from_snapshot( com_port: endpoint.com_port.clone(), }); } + Some(installed_endpoint) + if installed_endpoint + .com_port + .as_ref() + .is_some_and(|port| port.eq_ignore_ascii_case(&endpoint.com_port)) => + { + actions.push(ProvisionAction::Authorize { + stable_id: endpoint.stable_id.clone(), + instance_id: installed_endpoint.instance_id.clone(), + com_port: endpoint.com_port.clone(), + }); + } Some(installed_endpoint) => actions.push(ProvisionAction::Reassign { stable_id: endpoint.stable_id.clone(), instance_id: installed_endpoint.instance_id.clone(), @@ -553,12 +580,17 @@ mod platform { SPDRP_HARDWAREID, SP_DEVINFO_DATA, }; use windows_sys::Win32::Foundation::{ - GetLastError, ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, INVALID_HANDLE_VALUE, + CloseHandle, GetLastError, ERROR_INSUFFICIENT_BUFFER, ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, + INVALID_HANDLE_VALUE, + }; + use windows_sys::Win32::Security::{ + GetLengthSid, GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER, }; use windows_sys::Win32::System::Registry::{ RegCloseKey, RegOpenKeyExW, RegQueryValueExW, RegSetValueExW, HKEY, HKEY_LOCAL_MACHINE, KEY_READ, KEY_SET_VALUE, REG_BINARY, REG_SZ, }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; use windows_sys::Win32::UI::Shell::IsUserAnAdmin; use super::{ @@ -569,6 +601,7 @@ mod platform { const COM_NAME_ARBITER: &str = r"SYSTEM\CurrentControlSet\Control\COM Name Arbiter"; const COM_DATABASE_VALUE: &str = "ComDB"; const PORT_NAME_VALUE: &str = "PortName"; + const OWNER_SID_VALUE: &str = "CatHubOwnerSid"; const MAX_COM_PORTS: usize = 4096; type HComDb = *mut c_void; @@ -671,6 +704,7 @@ mod platform { pub(super) fn snapshot() -> Result { let set = DeviceInfoSet::all()?; + let current_sid = current_user_sid()?; let mut owned = Vec::new(); let mut claims = Vec::new(); let mut live_ports = BTreeSet::new(); @@ -703,6 +737,7 @@ mod platform { }); } if let Some(definition) = definition { + let owner_sid = device_binary_value(set.0, &data, OWNER_SID_VALUE); let display_name = device_property_strings(set.0, &data, SPDRP_FRIENDLYNAME) .into_iter() .next() @@ -719,6 +754,8 @@ mod platform { instance_id, display_name, com_port: port, + authorized_for_current_user: owner_sid.as_deref() + == Some(current_sid.as_slice()), }); } } @@ -756,6 +793,7 @@ mod platform { ))); } let database = ComDatabase::open()?; + let owner_sid = current_user_sid()?; for action in &plan.actions { match action { ProvisionAction::Create { @@ -774,6 +812,7 @@ mod platform { definition.hardware_id, definition.display_name, com_port, + &owner_sid, inf_path, &mut reboot_required, ) { @@ -788,7 +827,7 @@ mod platform { .. } => { database.claim(to)?; - if let Err(error) = set_existing_port(instance_id, to) { + if let Err(error) = set_existing_port(instance_id, to, &owner_sid) { let _ = database.release(to); return Err(error); } @@ -796,6 +835,9 @@ mod platform { database.release(from)?; } } + ProvisionAction::Authorize { instance_id, .. } => { + set_existing_owner(instance_id, &owner_sid)?; + } ProvisionAction::Retain { .. } => {} } } @@ -842,6 +884,7 @@ mod platform { hardware_id: &str, display_name: &str, com_port: &str, + owner_sid: &[u8], inf_path: &Path, reboot_required: &mut i32, ) -> Result<(), String> { @@ -890,6 +933,7 @@ mod platform { } let result = (|| { set_port_name(set.0, &data, com_port)?; + set_owner_sid(set.0, &data, owner_sid)?; let hardware_id = wide(hardware_id); let inf = wide(&inf_path.to_string_lossy()); // SAFETY: Both input strings are NUL-terminated and the reboot output is valid. @@ -916,9 +960,14 @@ mod platform { result } - fn set_existing_port(instance_id: &str, com_port: &str) -> Result<(), String> { + fn set_existing_port( + instance_id: &str, + com_port: &str, + owner_sid: &[u8], + ) -> Result<(), String> { with_device(instance_id, |set, data| { set_port_name(set, data, com_port)?; + set_owner_sid(set, data, owner_sid)?; // SAFETY: The data belongs to the live set and identifies the exact CatHub instance. if unsafe { SetupDiRestartDevices(set, data) } == 0 { return Err(last_error(&format!( @@ -929,6 +978,19 @@ mod platform { }) } + fn set_existing_owner(instance_id: &str, owner_sid: &[u8]) -> Result<(), String> { + with_device(instance_id, |set, data| { + set_owner_sid(set, data, owner_sid)?; + // SAFETY: Restart makes the driver reload the updated owner SID. + if unsafe { SetupDiRestartDevices(set, data) } == 0 { + return Err(last_error(&format!( + "restarting CatHub device `{instance_id}`" + ))); + } + Ok(()) + }) + } + fn with_device( instance_id: &str, operation: impl FnOnce(HDEVINFO, &mut SP_DEVINFO_DATA) -> Result<(), String>, @@ -975,6 +1037,48 @@ mod platform { result } + fn set_owner_sid( + set: HDEVINFO, + data: &SP_DEVINFO_DATA, + owner_sid: &[u8], + ) -> Result<(), String> { + // SAFETY: The device data belongs to the live information set. + let key = unsafe { + SetupDiOpenDevRegKey(set, data, DICS_FLAG_GLOBAL, 0, DIREG_DEV, KEY_SET_VALUE) + }; + if key as isize == INVALID_HANDLE_VALUE as isize { + return Err(last_error( + "opening the CatHub device security registry key", + )); + } + let name = wide(OWNER_SID_VALUE); + let length = u32::try_from(owner_sid.len()) + .map_err(|_| "the current user's SID is too large".to_string()); + let result = length.and_then(|length| { + // SAFETY: The key is open and the copied SID bytes remain valid through the call. + let status = unsafe { + RegSetValueExW( + key, + name.as_ptr(), + 0, + REG_BINARY, + owner_sid.as_ptr(), + length, + ) + }; + if status == ERROR_SUCCESS { + Ok(()) + } else { + Err(format!( + "setting CatHubOwnerSid failed with Win32 error {status}" + )) + } + }); + // SAFETY: This function owns the registry handle returned above. + unsafe { RegCloseKey(key) }; + result + } + fn device_port_name(set: HDEVINFO, data: &SP_DEVINFO_DATA) -> Option { // SAFETY: The device data belongs to the live information set. let key = @@ -988,6 +1092,53 @@ mod platform { value } + fn device_binary_value(set: HDEVINFO, data: &SP_DEVINFO_DATA, name: &str) -> Option> { + // SAFETY: The device data belongs to the live information set. + let key = + unsafe { SetupDiOpenDevRegKey(set, data, DICS_FLAG_GLOBAL, 0, DIREG_DEV, KEY_READ) }; + if key as isize == INVALID_HANDLE_VALUE as isize { + return None; + } + let name = wide(name); + let mut kind = 0; + let mut bytes = 0; + // SAFETY: Null data performs a size query on the open key. + let status = unsafe { + RegQueryValueExW( + key, + name.as_ptr(), + null(), + &raw mut kind, + null_mut(), + &raw mut bytes, + ) + }; + let mut value = if status == ERROR_SUCCESS && kind == REG_BINARY && bytes > 0 { + vec![0_u8; usize::try_from(bytes).ok()?] + } else { + Vec::new() + }; + if !value.is_empty() { + // SAFETY: The byte buffer matches the registry-reported size. + let status = unsafe { + RegQueryValueExW( + key, + name.as_ptr(), + null(), + &raw mut kind, + value.as_mut_ptr(), + &raw mut bytes, + ) + }; + if status != ERROR_SUCCESS { + value.clear(); + } + } + // SAFETY: This function owns the registry handle returned above. + unsafe { RegCloseKey(key) }; + (!value.is_empty()).then_some(value) + } + fn query_registry_string(key: HKEY, name: &str) -> Option { let name = wide(name); let mut kind = 0; @@ -1165,6 +1316,59 @@ mod platform { .to_string()) } + fn current_user_sid() -> Result, String> { + let mut token = null_mut(); + // SAFETY: The pseudo-process handle is valid and output storage receives an owned token. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) } == 0 { + return Err(last_error("opening the current process token")); + } + let result = (|| { + let mut bytes = 0; + // SAFETY: Null output requests the token-user buffer size. + unsafe { + GetTokenInformation(token, TokenUser, null_mut(), 0, &raw mut bytes); + } + // SAFETY: GetLastError immediately follows the expected size-query failure. + if bytes == 0 || unsafe { GetLastError() } != ERROR_INSUFFICIENT_BUFFER { + return Err(last_error("querying the current user SID length")); + } + let mut buffer = vec![ + 0_u8; + usize::try_from(bytes) + .map_err(|_| "the current user token is too large")? + ]; + // SAFETY: The buffer has the exact size requested by GetTokenInformation. + if unsafe { + GetTokenInformation( + token, + TokenUser, + buffer.as_mut_ptr().cast(), + bytes, + &raw mut bytes, + ) + } == 0 + { + return Err(last_error("reading the current user SID")); + } + // SAFETY: TOKEN_USER is the documented leading structure in this returned buffer. + let token_user = unsafe { (buffer.as_ptr().cast::()).read_unaligned() }; + // SAFETY: The SID pointer belongs to the live token-information buffer. + let sid_length = unsafe { GetLengthSid(token_user.User.Sid) }; + let sid_length = + usize::try_from(sid_length).map_err(|_| "the current user SID is too large")?; + if sid_length == 0 { + return Err(last_error("validating the current user SID")); + } + // SAFETY: GetLengthSid returned the byte length of this SID in the live buffer. + Ok(unsafe { + std::slice::from_raw_parts(token_user.User.Sid.cast::(), sid_length).to_vec() + }) + })(); + // SAFETY: This function owns the process token handle. + unsafe { CloseHandle(token) }; + result + } + fn require_administrator() -> Result<(), String> { // SAFETY: This parameterless shell helper checks membership in the local Administrators group. if unsafe { IsUserAnAdmin() } == 0 { @@ -1327,6 +1531,7 @@ dialect = "ts590" instance_id: r"ROOT\CATHUB_N1MM_CAT\0000".to_string(), display_name: "CatHub N1MM CAT Port".to_string(), com_port: Some("COM21".to_string()), + authorized_for_current_user: true, }], claims: vec![PortClaim { com_port: "COM21".to_string(), @@ -1339,6 +1544,43 @@ dialect = "ts590" assert!(!plan.requires_changes()); } + #[test] + fn installed_endpoint_with_another_owner_is_reauthorized() { + let config = config( + r#" +[radio] +backend = "loopback" +[[serial_endpoint]] +name = "n1mm" +virtual_endpoint = "n1mm-cat" +application_transport = "COM21" +dialect = "ts590" +"#, + ); + let snapshot = SystemSnapshot { + owned: vec![InstalledEndpoint { + stable_id: "n1mm-cat".to_string(), + kind: EndpointKind::Cat, + hardware_id: r"ROOT\CATHUB_N1MM_CAT".to_string(), + instance_id: r"ROOT\CATHUB_N1MM_CAT\0000".to_string(), + display_name: "CatHub N1MM CAT Port".to_string(), + com_port: Some("COM21".to_string()), + authorized_for_current_user: false, + }], + claims: vec![PortClaim { + com_port: "COM21".to_string(), + owner: r"ROOT\CATHUB_N1MM_CAT\0000".to_string(), + cathub_owned: true, + }], + }; + let plan = plan_from_snapshot(desired_endpoints(&config).unwrap(), &snapshot).unwrap(); + assert!(matches!( + plan.actions.first(), + Some(ProvisionAction::Authorize { .. }) + )); + assert!(plan.requires_changes()); + } + #[test] fn rejects_kind_mismatch_and_invalid_com_name() { assert!(desired_endpoint("wktools", EndpointKind::Cat, "bad", None).is_err()); diff --git a/docs/integration/windows-virtual-serial.md b/docs/integration/windows-virtual-serial.md index 1d55f60..d67fc97 100644 --- a/docs/integration/windows-virtual-serial.md +++ b/docs/integration/windows-virtual-serial.md @@ -62,6 +62,12 @@ Apply stages the package, creates only hardware IDs from CatHub's fixed allow-li requested COM numbers, installs or restarts the device, and verifies the resulting PnP state. It is idempotent: a second successful run reports retained endpoints and makes no changes. +Provisioning also records the invoking Windows user's SID as the private-channel owner. The COM +port remains usable by ordinary desktop serial clients, but the driver accepts the private +`cathub.exe` channel only when UMDF can impersonate a request from that owner. Run `apply` again as +the intended service user to transfer private-channel ownership; the plan reports this as an +`AUTHORIZE` action. Normal daemon and application operation does not require elevation. + Remove every CatHub-owned endpoint, or select stable IDs individually: ```powershell diff --git a/drivers/cathub-virtual-serial-umdf/Cargo.lock b/drivers/cathub-virtual-serial-umdf/Cargo.lock index c616618..76d069e 100644 --- a/drivers/cathub-virtual-serial-umdf/Cargo.lock +++ b/drivers/cathub-virtual-serial-umdf/Cargo.lock @@ -140,6 +140,7 @@ dependencies = [ "wdk", "wdk-build", "wdk-sys", + "windows-sys 0.59.0", ] [[package]] diff --git a/drivers/cathub-virtual-serial-umdf/Cargo.toml b/drivers/cathub-virtual-serial-umdf/Cargo.toml index 84ccbd6..3e57e35 100644 --- a/drivers/cathub-virtual-serial-umdf/Cargo.toml +++ b/drivers/cathub-virtual-serial-umdf/Cargo.toml @@ -24,6 +24,7 @@ wdk-build = { git = "https://github.com/microsoft/windows-drivers-rs", rev = "8e cathub-virtual-serial = { path = "../../crates/cathub-virtual-serial", default-features = false } wdk = { git = "https://github.com/microsoft/windows-drivers-rs", rev = "8e88dd899d9fa988df841e08cc01e9f663e5a415" } wdk-sys = { git = "https://github.com/microsoft/windows-drivers-rs", rev = "8e88dd899d9fa988df841e08cc01e9f663e5a415" } +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security"] } [dev-dependencies] wdk-sys = { features = ["test-stubs"], git = "https://github.com/microsoft/windows-drivers-rs", rev = "8e88dd899d9fa988df841e08cc01e9f663e5a415" } diff --git a/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx b/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx index 860c279..f988911 100644 --- a/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx +++ b/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx @@ -160,6 +160,7 @@ UmdfFsContextUsePolicy=CannotUseFsContexts [CatHubUMDFDevice_WdfInstall] UmdfLibraryVersion=$UMDFVERSION$ ServiceBinary=%13%\cathub_virtual_serial_umdf.dll +UmdfImpersonationLevel=Impersonation [SetDeviceType_AddReg] HKR,,DeviceType,0x10001,0x0000001b diff --git a/drivers/cathub-virtual-serial-umdf/src/interop.rs b/drivers/cathub-virtual-serial-umdf/src/interop.rs index c1d9e44..35d78d7 100644 --- a/drivers/cathub-virtual-serial-umdf/src/interop.rs +++ b/drivers/cathub-virtual-serial-umdf/src/interop.rs @@ -15,14 +15,16 @@ use std::{ use wdk::println; use wdk_sys::{ - _WDF_EXECUTION_LEVEL, _WDF_FILEOBJECT_CLASS, _WDF_IO_QUEUE_DISPATCH_TYPE, - _WDF_SYNCHRONIZATION_SCOPE, _WDF_TRI_STATE, BOOLEAN, GUID, KEY_QUERY_VALUE, NTSTATUS, - PCUNICODE_STRING, PDRIVER_OBJECT, PLUGPLAY_REGKEY_DEVICE, PVOID, ULONG, ULONG_PTR, - UNICODE_STRING, WDF_DRIVER_CONFIG, WDF_FILEOBJECT_CONFIG, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, - WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES, WDF_OBJECT_CONTEXT_TYPE_INFO, - WDF_TIMER_CONFIG, WDFDEVICE, WDFDEVICE_INIT, WDFDRIVER, WDFFILEOBJECT, WDFKEY, WDFOBJECT, - WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER, call_unsafe_wdf_function_binding, + _SECURITY_IMPERSONATION_LEVEL, _WDF_EXECUTION_LEVEL, _WDF_FILEOBJECT_CLASS, + _WDF_IO_QUEUE_DISPATCH_TYPE, _WDF_SYNCHRONIZATION_SCOPE, _WDF_TRI_STATE, BOOLEAN, GUID, + KEY_QUERY_VALUE, NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PLUGPLAY_REGKEY_DEVICE, PVOID, + ULONG, ULONG_PTR, UNICODE_STRING, WDF_DRIVER_CONFIG, WDF_FILEOBJECT_CONFIG, + WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES, + WDF_OBJECT_CONTEXT_TYPE_INFO, WDF_TIMER_CONFIG, WDFDEVICE, WDFDEVICE_INIT, WDFDRIVER, + WDFFILEOBJECT, WDFKEY, WDFOBJECT, WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER, + call_unsafe_wdf_function_binding, }; +use windows_sys::Win32::Security::{CheckTokenMembership, GetLengthSid, IsValidSid}; use crate::data_plane::{ChannelRole, DEFAULT_BUFFER_CAPACITY, DataPlaneError, EndpointDataPlane}; use crate::private_protocol::{ @@ -37,6 +39,7 @@ const STATUS_SUCCESS: NTSTATUS = 0; const STATUS_UNSUCCESSFUL: NTSTATUS = -1_073_741_823; const STATUS_INVALID_DEVICE_REQUEST: NTSTATUS = -1_073_741_808; const STATUS_INVALID_PARAMETER: NTSTATUS = -1_073_741_811; +const STATUS_ACCESS_DENIED: NTSTATUS = -1_073_741_790; const STATUS_OBJECT_NAME_INVALID: NTSTATUS = -1_073_741_773; const STATUS_SHARING_VIOLATION: NTSTATUS = -1_073_741_757; const STATUS_DEVICE_NOT_CONNECTED: NTSTATUS = -1_073_741_667; @@ -55,6 +58,9 @@ const STABLE_ID_VALUE: [u16; 15] = [ const DISPLAY_NAME_VALUE: [u16; 18] = [ 67, 97, 116, 72, 117, 98, 68, 105, 115, 112, 108, 97, 121, 78, 97, 109, 101, 0, ]; +const OWNER_SID_VALUE: [u16; 15] = [ + 67, 97, 116, 72, 117, 98, 79, 119, 110, 101, 114, 83, 105, 100, 0, +]; const DOS_DEVICE_PREFIX: &[u16] = &[ 92, 68, 111, 115, 68, 101, 118, 105, 99, 101, 115, 92, 71, 108, 111, 98, 97, 108, 92, ]; @@ -88,6 +94,7 @@ struct DeviceState { daemon_reads: AtomicPtr, wait_requests: AtomicPtr, pending_application_reads: Mutex>, + daemon_owner_sid: Box<[u8]>, } #[derive(Debug, Clone, Copy)] @@ -99,10 +106,10 @@ struct PendingRead { impl DeviceState { #[cfg(test)] fn new() -> Self { - Self::for_endpoint(EndpointMetadata::default()) + Self::for_endpoint(EndpointMetadata::default(), Box::default()) } - fn for_endpoint(endpoint: EndpointMetadata) -> Self { + fn for_endpoint(endpoint: EndpointMetadata, daemon_owner_sid: Box<[u8]>) -> Self { let mut serial = SerialState::default(); serial.set_modem_input(serial.modem_input()); Self { @@ -113,6 +120,7 @@ impl DeviceState { daemon_reads: AtomicPtr::new(ptr::null_mut()), wait_requests: AtomicPtr::new(ptr::null_mut()), pending_application_reads: Mutex::new(VecDeque::new()), + daemon_owner_sid, } } @@ -330,8 +338,11 @@ unsafe fn create_device(mut device_init: *mut WDFDEVICE_INIT) -> NTSTATUS { return STATUS_UNSUCCESSFUL; }; // SAFETY: The live WDF device owns a queryable PnP instance registry key. - let endpoint = unsafe { read_endpoint_metadata(device) }; - let state = Box::into_raw(Box::new(DeviceState::for_endpoint(endpoint))); + let (endpoint, daemon_owner_sid) = unsafe { read_endpoint_metadata(device) }; + let state = Box::into_raw(Box::new(DeviceState::for_endpoint( + endpoint, + daemon_owner_sid, + ))); // SAFETY: The context is exclusively initialized before queues or interfaces publish device. unsafe { (*context).state = state }; // SAFETY: `state` remains owned by the WDF device context until its destroy callback. @@ -487,7 +498,7 @@ unsafe fn register_interfaces(device: WDFDEVICE) -> NTSTATUS { } } -unsafe fn read_endpoint_metadata(device: WDFDEVICE) -> EndpointMetadata { +unsafe fn read_endpoint_metadata(device: WDFDEVICE) -> (EndpointMetadata, Box<[u8]>) { let mut metadata = EndpointMetadata::default(); let mut key: WDFKEY = ptr::null_mut(); // SAFETY: The device is live, null attributes are allowed, and output storage is valid. @@ -502,7 +513,7 @@ unsafe fn read_endpoint_metadata(device: WDFDEVICE) -> EndpointMetadata { ) }; if !nt_success(status) { - return metadata; + return (metadata, Box::default()); } let mut kind = 0_u32; @@ -527,9 +538,13 @@ unsafe fn read_endpoint_metadata(device: WDFDEVICE) -> EndpointMetadata { if let Some(display_name) = unsafe { query_registry_string(key, &DISPLAY_NAME_VALUE) } { metadata.display_name = display_name; } + // SAFETY: The key remains open through this bounded synchronous binary query. + let owner_sid = unsafe { query_registry_binary(key, &OWNER_SID_VALUE, 128) } + .unwrap_or_default() + .into_boxed_slice(); // SAFETY: This driver owns the WDF registry-key handle returned above. unsafe { call_unsafe_wdf_function_binding!(WdfRegistryClose, key) }; - metadata + (metadata, owner_sid) } unsafe fn query_registry_string(key: WDFKEY, value: &[u16]) -> Option { @@ -558,6 +573,43 @@ unsafe fn query_registry_string(key: WDFKEY, value: &[u16]) -> Option { (!value.is_empty()).then_some(value) } +unsafe fn query_registry_binary(key: WDFKEY, value: &[u16], maximum: usize) -> Option> { + let value_name = unicode_string(value); + let mut required_u32 = 0_u32; + let mut value_type = 0_u32; + // SAFETY: A zero-length query retrieves the value size and type synchronously. + let _status = unsafe { + call_unsafe_wdf_function_binding!( + WdfRegistryQueryValue, + key, + &raw const value_name, + 0, + ptr::null_mut(), + &raw mut required_u32, + &raw mut value_type, + ) + }; + let required = usize::try_from(required_u32).ok()?; + if required == 0 || required > maximum || value_type != 3 { + return None; + } + let mut buffer = vec![0_u8; required]; + let length = u32::try_from(buffer.len()).ok()?; + // SAFETY: The buffer matches the size returned by the first query and remains live. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfRegistryQueryValue, + key, + &raw const value_name, + length, + buffer.as_mut_ptr().cast(), + &raw mut required_u32, + &raw mut value_type, + ) + }; + (nt_success(status) && value_type == 3).then_some(buffer) +} + unsafe fn create_com_symbolic_link(device: WDFDEVICE) -> NTSTATUS { let mut key: WDFKEY = ptr::null_mut(); // SAFETY: The device is live, null attributes are allowed, and output storage is valid. @@ -626,6 +678,12 @@ unsafe extern "C" fn evt_device_file_create( let Some(role) = (unsafe { role_from_file(file) }) else { return RequestDisposition::error(STATUS_OBJECT_NAME_INVALID); }; + if role == ChannelRole::Daemon + // SAFETY: The create request is live and the immutable SID belongs to device state. + && !unsafe { daemon_request_is_authorized(request, &state.daemon_owner_sid) } + { + return RequestDisposition::error(STATUS_ACCESS_DENIED); + } let open_result = state.channel().open(role, file); match open_result { Ok(session) => { @@ -659,6 +717,52 @@ unsafe extern "C" fn evt_device_file_create( unsafe { finish_request(request, disposition) }; } +struct MembershipContext { + sid: *mut c_void, + checked: i32, + member: i32, +} + +unsafe fn daemon_request_is_authorized(request: WDFREQUEST, owner_sid: &[u8]) -> bool { + if owner_sid.is_empty() { + return false; + } + let sid = owner_sid.as_ptr().cast_mut().cast(); + // SAFETY: The SID pointer refers to the bounded registry value owned by device state. + if unsafe { IsValidSid(sid) } == 0 + // SAFETY: IsValidSid accepted this pointer, so querying its encoded length is valid. + || usize::try_from(unsafe { GetLengthSid(sid) }).ok() != Some(owner_sid.len()) + { + return false; + } + let mut context = MembershipContext { + sid, + checked: 0, + member: 0, + }; + // SAFETY: WDF invokes the callback synchronously while the stack context and SID are live. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfRequestImpersonate, + request, + _SECURITY_IMPERSONATION_LEVEL::SecurityImpersonation, + Some(evt_check_daemon_membership), + (&raw mut context).cast(), + ) + }; + nt_success(status) && context.checked != 0 && context.member != 0 +} + +unsafe extern "C" fn evt_check_daemon_membership(_request: WDFREQUEST, context: PVOID) { + // SAFETY: WDF passes the live stack context supplied to WdfRequestImpersonate. + let Some(context) = (unsafe { context.cast::().as_mut() }) else { + return; + }; + // SAFETY: WDF impersonates the requestor for this callback and the SID storage is live. + context.checked = + unsafe { CheckTokenMembership(ptr::null_mut(), context.sid, &raw mut context.member) }; +} + unsafe extern "C" fn evt_file_cleanup(file: WDFFILEOBJECT) { ffi_void(|| { // SAFETY: WDF keeps the file's parent device alive for this cleanup callback. From 457825ddca8977e354921b83e2fab73735838709 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 20:46:03 -0700 Subject: [PATCH 14/39] Exercise UMDF daemon failure and recovery --- scripts/Test-UmdfEndToEnd.ps1 | 69 +++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/scripts/Test-UmdfEndToEnd.ps1 b/scripts/Test-UmdfEndToEnd.ps1 index 0e038e1..f217e42 100644 --- a/scripts/Test-UmdfEndToEnd.ps1 +++ b/scripts/Test-UmdfEndToEnd.ps1 @@ -175,6 +175,21 @@ try { } $results.cases += [ordered]@{ name = 'initial_id_query'; passed = $true; response = $id } + $stressTimer = [System.Diagnostics.Stopwatch]::StartNew() + foreach ($iteration in 1..100) { + $stressId = Invoke-CatQuery -Port $serial -Command 'ID;' + if ($stressId -ne 'ID021;') { + throw "Unexpected stress response '$stressId' at iteration $iteration." + } + } + $stressTimer.Stop() + $results.cases += [ordered]@{ + name = 'repeated_bidirectional_io' + passed = $true + iterations = 100 + elapsed_ms = $stressTimer.ElapsedMilliseconds + } + $serial.Write('FA00014074000;') $frequency = Invoke-CatQuery -Port $serial -Command 'FA;' if ($frequency -ne 'FA00014074000;') { @@ -204,6 +219,60 @@ try { passed = $true response = $reopenedId } + + Stop-Process -Id $process.Id -Force + $process.WaitForExit() + $process = $null + $serial.ReadTimeout = 1000 + $failureTimer = [System.Diagnostics.Stopwatch]::StartNew() + $boundedFailure = $null + try { + $serial.Write('ID;') + $null = $serial.ReadTo(';') + } + catch { + $boundedFailure = $_.Exception.Message + } + $failureTimer.Stop() + if (-not $boundedFailure) { + throw 'Application I/O unexpectedly succeeded after the CatHub daemon was terminated.' + } + if ($failureTimer.ElapsedMilliseconds -gt 2000) { + throw "Daemon-loss I/O failure took $($failureTimer.ElapsedMilliseconds) ms." + } + $results.cases += [ordered]@{ + name = 'daemon_crash_fails_io_bounded' + passed = $true + elapsed_ms = $failureTimer.ElapsedMilliseconds + error = $boundedFailure + } + $serial.Close() + $serial.Dispose() + $serial = $null + + $process = Start-Process -FilePath $CatHubExe ` + -ArgumentList @('--config', $configPath) ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath ` + -PassThru + Start-Sleep -Seconds 2 + if ($process.HasExited) { + throw "CatHub exited after restart with code $($process.ExitCode)." + } + + $serial = [System.IO.Ports.SerialPort]::new($portName, 9600) + $serial.ReadTimeout = 5000 + $serial.WriteTimeout = 5000 + $serial.Open() + $restartId = Invoke-CatQuery -Port $serial -Command 'ID;' + if ($restartId -ne 'ID021;') { + throw "Unexpected ID response after daemon restart '$restartId'." + } + $results.cases += [ordered]@{ + name = 'daemon_restart_reconnect' + passed = $true + response = $restartId + } $results.passed = $true } catch { From 8a3b6e09420a3bf5e9bcd910aa71c8e9b5c183c9 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 20:46:48 -0700 Subject: [PATCH 15/39] Record UMDF package provenance and checksums --- drivers/cathub-virtual-serial-umdf/README.md | 8 +-- scripts/Test-UmdfPoc.ps1 | 55 ++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/drivers/cathub-virtual-serial-umdf/README.md b/drivers/cathub-virtual-serial-umdf/README.md index fc53354..24666c5 100644 --- a/drivers/cathub-virtual-serial-umdf/README.md +++ b/drivers/cathub-virtual-serial-umdf/README.md @@ -93,6 +93,8 @@ negotiation, discovery, attach/detach, lifecycle events, framed data, receive cr modem events, purge, health checks, and deterministic protocol rejection. Its application data is kept separate from driver control frames. -The private interface is not yet restricted to a service SID. The application COM path, private -CatHub adapter, and driver package must still be verified together on the isolated -driver-development target. +The private daemon path uses UMDF request impersonation. Provisioning stores the invoking Windows +user SID in the device instance; a daemon create request is accepted only when that SID is a member +of the impersonated caller token. The public COM path does not use this private authorization check. +The application COM path, private CatHub adapter, and driver package must still be verified +together on the isolated driver-development target. diff --git a/scripts/Test-UmdfPoc.ps1 b/scripts/Test-UmdfPoc.ps1 index 03a15a2..d770491 100644 --- a/scripts/Test-UmdfPoc.ps1 +++ b/scripts/Test-UmdfPoc.ps1 @@ -184,6 +184,56 @@ function New-EphemeralDriverCertificate { } } +function Write-PackageManifest { + param( + [Parameter(Mandatory)][string]$PackageRoot, + [Parameter(Mandatory)][string]$CertificatePath + ) + + $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + $CertificatePath + ) + try { + $files = @(Get-ChildItem -LiteralPath $PackageRoot -File | + Where-Object Name -ne 'package-manifest.json' | + Sort-Object Name | + ForEach-Object { + [ordered]@{ + name = $_.Name + length = $_.Length + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + } + }) + $manifest = [ordered]@{ + schema_version = 1 + generated_at_utc = [DateTime]::UtcNow.ToString('o') + package = 'cathub-virtual-serial-umdf' + architecture = 'x86_64-pc-windows-msvc' + git_revision = (& git -C $driverRoot rev-parse HEAD).Trim() + rustc = (& rustc --version).Trim() + windows_drivers_rs_revision = '8e88dd899d9fa988df841e08cc01e9f663e5a415' + wdk_nuget = 'Microsoft.Windows.WDK.x64 10.0.28000.2526' + umdf = '2.33' + signing = [ordered]@{ + purpose = 'isolated development test only' + subject = $certificate.Subject + thumbprint = $certificate.Thumbprint + not_after_utc = $certificate.NotAfter.ToUniversalTime().ToString('o') + } + files = $files + } + $json = $manifest | ConvertTo-Json -Depth 6 + [System.IO.File]::WriteAllText( + (Join-Path $PackageRoot 'package-manifest.json'), + $json, + [System.Text.UTF8Encoding]::new($false) + ) + } + finally { + $certificate.Dispose() + } +} + Assert-Command cargo Assert-Command clang Initialize-WdkEnvironment @@ -203,6 +253,10 @@ try { 'target\x86_64-pc-windows-msvc\debug\cathub_virtual_serial_umdf_package' $catalogPath = Join-Path $packageRoot 'cathub_virtual_serial_umdf.cat' $certificatePath = Join-Path $packageRoot 'cathub_umdf_test.cer' + $manifestPath = Join-Path $packageRoot 'package-manifest.json' + if (Test-Path -LiteralPath $manifestPath) { + Remove-Item -LiteralPath $manifestPath -Force + } $pfxPath = Join-Path ([System.IO.Path]::GetTempPath()) ` "cathub-umdf-$([guid]::NewGuid().ToString('N')).pfx" $password = [guid]::NewGuid().ToString('N') @@ -214,6 +268,7 @@ try { Invoke-Checked $signTool @( 'sign', '/v', '/fd', 'SHA256', '/f', $pfxPath, '/p', $password, $catalogPath ) + Write-PackageManifest -PackageRoot $packageRoot -CertificatePath $certificatePath } finally { if (Test-Path -LiteralPath $pfxPath) { From 18f811984ae39a5185423f1bfc4ffe89bfcfc57d Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 20:50:21 -0700 Subject: [PATCH 16/39] Track serial conformance executable --- .gitignore | 2 + .../src/bin/serial-conformance.rs | 66 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 crates/cathub-virtual-serial/src/bin/serial-conformance.rs diff --git a/.gitignore b/.gitignore index 0d175d5..aacf22f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ target artifacts .vs **/bin +!crates/cathub-virtual-serial/src/bin/ +!crates/cathub-virtual-serial/src/bin/serial-conformance.rs **/obj # These are backup files generated by rustfmt diff --git a/crates/cathub-virtual-serial/src/bin/serial-conformance.rs b/crates/cathub-virtual-serial/src/bin/serial-conformance.rs new file mode 100644 index 0000000..03413c0 --- /dev/null +++ b/crates/cathub-virtual-serial/src/bin/serial-conformance.rs @@ -0,0 +1,66 @@ +//! Windows serial API conformance command. + +use std::path::PathBuf; + +use cathub_virtual_serial::conformance::{profiles, run, ConformanceError}; +use clap::{Parser, Subcommand}; + +#[derive(Debug, Parser)] +#[command( + name = "serial-conformance", + version, + about = "Test a virtual serial pair with the Windows serial API" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// List the supported application profiles as JSON. + Profiles, + /// Run one profile against an isolated virtual serial pair. + Run { + /// Application-facing COM port. + #[arg(long)] + application_port: String, + /// Paired COM port that the harness uses as the transport peer. + #[arg(long)] + peer_port: String, + /// Stable profile name from the profiles command. + #[arg(long)] + profile: String, + /// Optional JSON report path. + #[arg(long)] + output: Option, + }, +} + +fn main() -> Result<(), ConformanceError> { + match Cli::parse().command { + Command::Profiles => { + println!("{}", serde_json::to_string_pretty(profiles())?); + Ok(()) + } + Command::Run { + application_port, + peer_port, + profile, + output, + } => { + let report = run(&profile, &application_port, &peer_port)?; + println!("{}", serde_json::to_string_pretty(&report)?); + if let Some(path) = output { + report.write_json(&path)?; + } + if report.required_cases_pass() { + Ok(()) + } else { + Err(ConformanceError::InvalidResult( + "one or more required conformance cases failed".to_string(), + )) + } + } + } +} From dc088b45227de0192fabb8731012bd84990ac3f2 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 20:51:56 -0700 Subject: [PATCH 17/39] Build pure Rust UMDF driver in CI --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ scripts/Test-UmdfPoc.ps1 | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3dda1d..273ebf4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,3 +46,28 @@ jobs: with: dotnet-version: 10.0.x - run: dotnet build CatHub.slnx -c Release + + umdf-driver: + runs-on: windows-latest + steps: + - uses: actions/checkout@v7.0.0 + - uses: dtolnay/rust-toolchain@1.91.0 + with: + components: rustfmt, clippy + targets: x86_64-pc-windows-msvc + - uses: NuGet/setup-nuget@v4.0 + with: + nuget-version: 6.14.3 + - name: Restore pinned WDK + shell: pwsh + run: | + $packageRoot = Join-Path $env:LOCALAPPDATA 'CatHub\wdk\packages' + nuget install Microsoft.Windows.WDK.x64 ` + -Version 10.0.28000.2526 ` + -OutputDirectory $packageRoot ` + -NonInteractive ` + -DirectDownload ` + -Source https://api.nuget.org/v3/index.json + - name: Build, test, and lint pure-Rust UMDF driver + shell: pwsh + run: .\scripts\Test-UmdfPoc.ps1 diff --git a/scripts/Test-UmdfPoc.ps1 b/scripts/Test-UmdfPoc.ps1 index d770491..5f073ac 100644 --- a/scripts/Test-UmdfPoc.ps1 +++ b/scripts/Test-UmdfPoc.ps1 @@ -289,7 +289,7 @@ try { else { Invoke-Checked cargo @('fmt', '--all', '--', '--check') Invoke-Checked cargo @( - 'check', '--target', 'x86_64-pc-windows-msvc', '--locked' + 'build', '--target', 'x86_64-pc-windows-msvc', '--locked' ) Invoke-Checked cargo @( 'test', '--target', 'x86_64-pc-windows-msvc', '--locked' From 830431070ab2da2d0b820a50cd862b6d626c3098 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 20:54:51 -0700 Subject: [PATCH 18/39] Package UMDF driver from locked release build --- drivers/cathub-virtual-serial-umdf/README.md | 4 ++-- scripts/Test-UmdfPoc.ps1 | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/drivers/cathub-virtual-serial-umdf/README.md b/drivers/cathub-virtual-serial-umdf/README.md index 24666c5..6e04ae8 100644 --- a/drivers/cathub-virtual-serial-umdf/README.md +++ b/drivers/cathub-virtual-serial-umdf/README.md @@ -48,13 +48,13 @@ From the repository root, run the driver-only check: .\scripts\Test-UmdfPoc.ps1 ``` -To build and validate an unsigned package without creating a certificate: +To build and validate a locked release package without creating a certificate: ```powershell .\scripts\Test-UmdfPoc.ps1 -Action ValidatePackage ``` -Produce a test-signed package for an isolated driver-development target with: +Produce a test-signed release package for an isolated driver-development target with: ```powershell .\scripts\Test-UmdfPoc.ps1 -Action Package diff --git a/scripts/Test-UmdfPoc.ps1 b/scripts/Test-UmdfPoc.ps1 index 5f073ac..befb9d7 100644 --- a/scripts/Test-UmdfPoc.ps1 +++ b/scripts/Test-UmdfPoc.ps1 @@ -246,11 +246,12 @@ try { } $signTool = Find-SignTool Invoke-Checked cargo @( - 'make', 'package-unsigned', '--target', 'x86_64-pc-windows-msvc' + 'make', 'package-unsigned', '--release', '--locked', + '--target', 'x86_64-pc-windows-msvc' ) $packageRoot = Join-Path $driverRoot ` - 'target\x86_64-pc-windows-msvc\debug\cathub_virtual_serial_umdf_package' + 'target\x86_64-pc-windows-msvc\release\cathub_virtual_serial_umdf_package' $catalogPath = Join-Path $packageRoot 'cathub_virtual_serial_umdf.cat' $certificatePath = Join-Path $packageRoot 'cathub_umdf_test.cer' $manifestPath = Join-Path $packageRoot 'package-manifest.json' @@ -283,7 +284,8 @@ try { Assert-Command $command } Invoke-Checked cargo @( - 'make', 'package-unsigned', '--target', 'x86_64-pc-windows-msvc' + 'make', 'package-unsigned', '--release', '--locked', + '--target', 'x86_64-pc-windows-msvc' ) } else { From 0798f128431c9a2ecbd47baf59cf4cf50ab3c01a Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 21:01:55 -0700 Subject: [PATCH 19/39] Keep managed serial stubs portable --- crates/cathub/src/managed_virtual_serial.rs | 57 +++++++------------ .../cathub/src/virtual_serial_provisioning.rs | 1 + 2 files changed, 21 insertions(+), 37 deletions(-) diff --git a/crates/cathub/src/managed_virtual_serial.rs b/crates/cathub/src/managed_virtual_serial.rs index e54f784..c9d1273 100644 --- a/crates/cathub/src/managed_virtual_serial.rs +++ b/crates/cathub/src/managed_virtual_serial.rs @@ -1,14 +1,21 @@ //! Bridge between CatHub endpoint sessions and the private UMDF transport. use std::io; +#[cfg(windows)] use std::time::Duration; +#[cfg(not(windows))] +use tokio::io::DuplexStream; +#[cfg(windows)] use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream}; +#[cfg(windows)] const BRIDGE_CAPACITY: usize = 64 * 1024; +#[cfg(windows)] const MESSAGE_CAPACITY: usize = 128; /// Open and attach to one driver-managed virtual serial endpoint. +#[cfg(windows)] pub(crate) async fn open(stable_id: &str, expected_kind: u16) -> io::Result { let stable_id = stable_id.to_owned(); let worker = tokio::task::spawn_blocking(move || platform::connect(&stable_id, expected_kind)) @@ -17,6 +24,19 @@ pub(crate) async fn open(stable_id: &str, expected_kind: u16) -> io::Result std::future::Ready> { + std::future::ready(Err(io::Error::new( + io::ErrorKind::Unsupported, + "managed virtual serial endpoints require Windows", + ))) +} + +#[cfg(windows)] fn spawn_bridge(mut worker: platform::Worker) -> DuplexStream { let (session, mut bridge) = tokio::io::duplex(BRIDGE_CAPACITY); tokio::spawn(async move { @@ -81,43 +101,6 @@ fn spawn_bridge(mut worker: platform::Worker) -> DuplexStream { session } -#[cfg(not(windows))] -mod platform { - use std::io; - - use cathub_virtual_serial::daemon::SerialConfiguration; - use tokio::sync::mpsc; - - pub(super) enum Command { - Data(Vec), - Release(usize), - Health, - Shutdown, - } - - pub(super) enum Event { - Data(Vec), - ApplicationOpen(u64), - ApplicationClose(u64), - SerialConfiguration(SerialConfiguration), - ModemControl(u32), - Closed, - Failed(String), - } - - pub(super) struct Worker { - pub(super) commands: mpsc::Sender, - pub(super) events: mpsc::Receiver, - } - - pub(super) fn connect(_stable_id: &str, _expected_kind: u16) -> io::Result { - Err(io::Error::new( - io::ErrorKind::Unsupported, - "managed virtual serial endpoints require Windows", - )) - } -} - #[cfg(windows)] #[allow(unsafe_code)] mod platform { diff --git a/crates/cathub/src/virtual_serial_provisioning.rs b/crates/cathub/src/virtual_serial_provisioning.rs index ca3475d..01ee3e4 100644 --- a/crates/cathub/src/virtual_serial_provisioning.rs +++ b/crates/cathub/src/virtual_serial_provisioning.rs @@ -433,6 +433,7 @@ fn definition(stable_id: &str) -> Option<&'static EndpointDefinition> { .find(|endpoint| endpoint.stable_id.eq_ignore_ascii_case(stable_id)) } +#[cfg(windows)] fn definition_for_hardware_id(hardware_id: &str) -> Option<&'static EndpointDefinition> { ENDPOINTS .iter() From 7cf7acb9ad5c50ea23984d8ed941079c5dd168b2 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 21:03:52 -0700 Subject: [PATCH 20/39] Exercise UMDF device restart recovery --- scripts/Test-UmdfEndToEnd.ps1 | 82 +++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/scripts/Test-UmdfEndToEnd.ps1 b/scripts/Test-UmdfEndToEnd.ps1 index f217e42..ff46158 100644 --- a/scripts/Test-UmdfEndToEnd.ps1 +++ b/scripts/Test-UmdfEndToEnd.ps1 @@ -22,7 +22,7 @@ function Invoke-Checked { } } -function Find-CatHubPort { +function Find-CatHubDevice { $deadline = [DateTime]::UtcNow.AddSeconds(20) do { $device = Get-PnpDevice -Class Ports -PresentOnly -ErrorAction SilentlyContinue | @@ -31,7 +31,10 @@ function Find-CatHubPort { if ($device) { $match = [regex]::Match($device.FriendlyName, '\((COM\d+)\)') if ($match.Success) { - return $match.Groups[1].Value + return [pscustomobject]@{ + InstanceId = $device.InstanceId + Port = $match.Groups[1].Value + } } $enumPath = "HKLM:\SYSTEM\CurrentControlSet\Enum\$($device.InstanceId)" @@ -39,7 +42,10 @@ function Find-CatHubPort { $portName = (Get-ItemProperty -LiteralPath $path -Name PortName ` -ErrorAction SilentlyContinue).PortName if ($portName -match '^COM\d+$') { - return $portName + return [pscustomobject]@{ + InstanceId = $device.InstanceId + Port = $portName + } } } } @@ -113,13 +119,15 @@ Invoke-Checked $CatHubExe @( '--format', 'json' ) -$portName = Find-CatHubPort +$device = Find-CatHubDevice +$portName = $device.Port $process = $null $serial = $null $results = [ordered]@{ timestamp_utc = [DateTime]::UtcNow.ToString('o') machine = $env:COMPUTERNAME port = $portName + device_instance_id = $device.InstanceId driver_certificate = $certificate.Thumbprint cases = @() } @@ -273,6 +281,72 @@ try { passed = $true response = $restartId } + + Invoke-Checked pnputil @('/restart-device', $device.InstanceId) + $serial.ReadTimeout = 1000 + $deviceFailureTimer = [System.Diagnostics.Stopwatch]::StartNew() + $deviceFailure = $null + try { + $serial.Write('ID;') + $null = $serial.ReadTo(';') + } + catch { + $deviceFailure = $_.Exception.Message + } + $deviceFailureTimer.Stop() + if (-not $deviceFailure) { + throw 'The pre-restart COM handle unexpectedly remained usable after device restart.' + } + if ($deviceFailureTimer.ElapsedMilliseconds -gt 3000) { + throw "Device-restart I/O failure took $($deviceFailureTimer.ElapsedMilliseconds) ms." + } + $serial.Close() + $serial.Dispose() + $serial = $null + + $restartedDevice = Find-CatHubDevice + if ($restartedDevice.InstanceId -ne $device.InstanceId) { + throw "Device restart changed instance ID to '$($restartedDevice.InstanceId)'." + } + + $reconnectDeadline = [DateTime]::UtcNow.AddSeconds(20) + $deviceRestartId = $null + $lastReconnectError = $null + do { + try { + $serial = [System.IO.Ports.SerialPort]::new($portName, 9600) + $serial.ReadTimeout = 1000 + $serial.WriteTimeout = 1000 + $serial.Open() + $deviceRestartId = Invoke-CatQuery -Port $serial -Command 'ID;' + if ($deviceRestartId -eq 'ID021;') { + break + } + $lastReconnectError = "unexpected response '$deviceRestartId'" + } + catch { + $lastReconnectError = $_.Exception.Message + } + if ($serial) { + if ($serial.IsOpen) { + $serial.Close() + } + $serial.Dispose() + $serial = $null + } + Start-Sleep -Milliseconds 500 + } while ([DateTime]::UtcNow -lt $reconnectDeadline) + + if ($deviceRestartId -ne 'ID021;') { + throw "CatHub did not reconnect after device restart: $lastReconnectError" + } + $results.cases += [ordered]@{ + name = 'umdf_device_restart_reconnect' + passed = $true + failure_elapsed_ms = $deviceFailureTimer.ElapsedMilliseconds + failure_error = $deviceFailure + response = $deviceRestartId + } $results.passed = $true } catch { From c24eb208152952a52bf52d6f2801ec676ab33db8 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 21:05:07 -0700 Subject: [PATCH 21/39] Document CatHub-owned Windows serial setup --- docs/design/multi-client-cat-hub.md | 13 +++---- docs/integration/setup.md | 56 +++++++++++++++++------------ 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/docs/design/multi-client-cat-hub.md b/docs/design/multi-client-cat-hub.md index 87bee55..fb17ca9 100644 --- a/docs/design/multi-client-cat-hub.md +++ b/docs/design/multi-client-cat-hub.md @@ -33,11 +33,11 @@ are outside the 0.1 scope. ```text radio applications - |-- private virtual serial pairs ------| + |-- private virtual serial endpoints --| `-- dedicated Hamlib NET listeners ----|--> CatHub --> radio or private rigctld CW applications - |-- private virtual serial pairs ------| + |-- private virtual serial endpoints --| `-- loopback typed gRPC ---------------|--> CatHub --> physical WinKeyer ``` @@ -99,9 +99,10 @@ Captured transcripts and tests define compatibility. CatHub does not claim suppo ### Serial CAT -Each `[[serial_endpoint]]` binds the daemon side of a virtual serial pair. The application -opens `application_transport`, the other side of that pair. A configured dialect parses the -client's command stream and translates modeled operations into the shared scheduler. +Each `[[serial_endpoint]]` either selects a CatHub-owned Windows UMDF endpoint with +`virtual_endpoint` or binds the daemon side of an externally provisioned virtual serial pair with +`transport`. The application opens `application_transport` in either case. A configured dialect +parses the client's command stream and translates modeled operations into the shared scheduler. The transparent TS-590 dialect relays the real dual-VFO stream and therefore cannot use single-VFO presentation. Modeled TS-590 and TS-2000 endpoints can enable `single_vfo` when a @@ -162,7 +163,7 @@ perms = ["read"] [[serial_endpoint]] name = "contest-logger" -transport = "COM20" +virtual_endpoint = "n1mm-cat" application_transport = "COM21" dialect = "ts590" single_vfo = true diff --git a/docs/integration/setup.md b/docs/integration/setup.md index 3e3dc1b..fad4ea9 100644 --- a/docs/integration/setup.md +++ b/docs/integration/setup.md @@ -2,7 +2,7 @@ This guide configures one CatHub process to own a radio and optional WinKeyer while several applications connect through dedicated endpoints. The examples use Windows, a Kenwood -TS-590, com0com virtual serial pairs, and common amateur-radio applications. Substitute the +TS-590, CatHub-owned virtual COM endpoints, and common amateur-radio applications. Substitute the ports and clients used by your station. ## 1. Install CatHub @@ -55,34 +55,31 @@ Remove startup tasks that can start an old bridge. Do not proceed until the physical ports are free. -## 3. Create virtual serial pairs +## 3. Choose serial endpoints -Applications that require a COM port need one dedicated null-modem pair each. CatHub opens -one side and the application opens the other. Hamlib NET and typed gRPC clients use TCP and -do not need a pair. +On Windows 11, applications that require a COM port should use one CatHub-owned UMDF endpoint +each. CatHub attaches through a private device interface, so only the application-facing port is +visible and no null-modem peer is required. Hamlib NET and typed gRPC clients remain driver-free. -Example com0com pairs: +The sample configuration requests: ```text -COM10 <-> COM11 SDR software through OmniRig -COM20 <-> COM21 contest logger radio CAT -COM30 <-> COM31 manufacturer control panel -COM40 <-> COM41 contest logger WinKeyer -COM42 <-> COM43 WinKeyer maintenance tool +COM11 SDR software through OmniRig +COM21 contest logger radio CAT +COM31 manufacturer control panel +COM41 contest logger WinKeyer +COM43 WinKeyer maintenance tool ``` -With com0com's `setupc` utility: +Do not create these ports manually. Configure their stable endpoint IDs in `cathub.toml`, then use +the plan/apply commands after validation. CatHub refuses to delete or repurpose an existing +physical, com0com, or third-party virtual device. See +[CatHub-owned Windows virtual serial endpoints](windows-virtual-serial.md) for package, signing, +and migration details. -```text -install PortName=COM10 PortName=COM11 -install PortName=COM20 PortName=COM21 -install PortName=COM30 PortName=COM31 -install PortName=COM40 PortName=COM41 -install PortName=COM42 PortName=COM43 -``` - -The lower, even port in this example is CatHub's `transport`. The other port is -`application_transport`. Never point an application at CatHub's side of the pair. +Physical ports and externally provisioned virtual pairs remain supported. For those endpoints, +keep using `transport` for the path CatHub opens and `application_transport` for the paired path +the client opens. Do not set `virtual_endpoint` on an externally managed pair. On Linux, use stable PTY or virtual-serial endpoints managed by the host. Ensure the CatHub service account can open the radio, keyer, and hub-side paths. @@ -142,6 +139,19 @@ cargo run -p cathub -- config print-effective --config config\cathub.toml Correct every validation error before starting the daemon. +On Windows, inspect and provision the configured CatHub-owned endpoints from an elevated terminal +using the signed driver package shipped with the matching CatHub build: + +```powershell +cathub virtual-serial status +cathub virtual-serial plan +cathub virtual-serial apply ` + --inf C:\ProgramData\CatHub\driver\cathub_virtual_serial_umdf.inf +``` + +Run `plan` again after `apply`; every requested endpoint should be retained with its expected COM +name and authorized owner. Normal CatHub operation and client use do not require elevation. + ## 6. Start and inspect CatHub Start an installed daemon: @@ -255,7 +265,7 @@ Then do these tests: |---|---| | Physical port is busy | Stop every direct radio/keyer client and old bridge. CatHub must be the only physical owner. | | Radio opens but does not answer | Match `[radio].baud` to the radio menu and verify the physical port. | -| Serial client cannot open a port | The client must open `application_transport`, not CatHub's `transport`. | +| Serial client cannot open a port | For a managed endpoint, check `cathub virtual-serial status`; for an external pair, the client must open `application_transport`, not CatHub's `transport`. | | Hamlib NET client cannot connect | Confirm the listener address, firewall policy, and that CatHub completed startup. | | Writes return not supported | Check endpoint permissions and whether the command is modeled for that dialect. | | Digital mode stops on VFO B | Enable `single_vfo` for that client and use fake split. | From d8c526b88a62f357d10ee24102dc3d2fc80fcbd6 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 21:12:57 -0700 Subject: [PATCH 22/39] Document tonic status lint exception --- crates/cathub/src/winkeyer/grpc.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/cathub/src/winkeyer/grpc.rs b/crates/cathub/src/winkeyer/grpc.rs index b85c1dc..df3686a 100644 --- a/crates/cathub/src/winkeyer/grpc.rs +++ b/crates/cathub/src/winkeyer/grpc.rs @@ -47,6 +47,9 @@ impl Service { } } + // Tonic prescribes `Status` as the service error type, so boxing it here would only force + // every generated service method to allocate and immediately unbox the same value. + #[allow(clippy::result_large_err)] async fn client_id(&self, name: &str) -> Result { let name = name.trim(); if name.is_empty() || name.len() > 64 { From 0d841a5a872b154df0bc41c638ec9280cdc73f58 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 21:14:05 -0700 Subject: [PATCH 23/39] Record virtual serial signing decision gate --- README.md | 1 + .../design/virtual-serial-signing-decision.md | 66 +++++++++++++++++++ docs/integration/windows-virtual-serial.md | 4 +- 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 docs/design/virtual-serial-signing-decision.md diff --git a/README.md b/README.md index e91e1b9..8373398 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ Registry publication is a separate authorized operation. See - [Serial client inventory](docs/testing/serial-client-inventory.md) - [Operator setup](docs/integration/setup.md) - [Windows CatHub-owned virtual serial](docs/integration/windows-virtual-serial.md) +- [Virtual serial signing decision](docs/design/virtual-serial-signing-decision.md) - [Release and compatibility](docs/architecture/release-and-compatibility.md) ## License diff --git a/docs/design/virtual-serial-signing-decision.md b/docs/design/virtual-serial-signing-decision.md new file mode 100644 index 0000000..8029115 --- /dev/null +++ b/docs/design/virtual-serial-signing-decision.md @@ -0,0 +1,66 @@ +# Virtual serial signing decision gate + +Status: research complete; provider approval and clean-system evidence pending. + +## What is established + +- Windows Plug and Play treats a signed catalog as the signature for the complete driver package. + Microsoft documents Authenticode as a supported way to sign a catalog after package contents are + finalized: [Catalog files and digital signatures](https://learn.microsoft.com/en-us/windows-hardware/drivers/install/catalog-files). +- SignPath's current artifact schema explicitly supports Authenticode signing of `.cat` files with + the `catalog-file` element: [Artifact Configuration Reference](https://docs.signpath.io/artifact-configuration/reference). +- SignPath Foundation currently publishes at least one accepted virtual-driver project, the + [Virtual Display Driver](https://signpath.org/projects/virtual-display-driver/). This is useful + policy precedent, but it is not approval of CatHub or proof that the resulting certificate will + satisfy CatHub's PnP installation scenario. +- Microsoft's Hardware Dev Center path still requires an EV certificate to establish the hardware + dashboard account for attestation or WHCP submission: + [Driver code signing requirements](https://learn.microsoft.com/en-us/windows-hardware/drivers/dashboard/code-signing-reqs). + +The repository's ephemeral self-signed catalog is development-test material only. It deliberately +requires the isolated VM to trust its public test certificate and is not a production signing path. + +## SignPath request + +Before purchasing an EV certificate, apply to SignPath Foundation and request an explicit written +answer to all of these questions: + +1. Will the Foundation program approve CatHub and allow its certificate to sign the + `cathub_virtual_serial_umdf.cat` driver-package catalog? +2. Is that certificate intended to satisfy direct PnP installation of a pure UMDF 2 package on + supported x64 Windows 11 systems? +3. Are driver catalogs subject to additional review, build-origin, release-approval, or timestamp + requirements beyond ordinary executable signing? +4. May the signed catalog be distributed with the INF, UMDF DLL, symbols, provenance manifest, and + installer outside the Microsoft Store and Windows Update? + +A minimal SignPath artifact configuration for technical validation is: + +```xml + + + + + + + +``` + +Provider approval must precede wiring this configuration into release CI. + +## Clean-system acceptance + +After obtaining a public signature, create a fresh Windows 11 x64 VM with Secure Boot and Memory +Integrity enabled. Do not import a CatHub or provider certificate and do not enable test signing. +Use the proposed installer flow and preserve: + +- the exact elevation and publisher prompts; +- `signtool verify /pa /v` and certificate-chain output for the catalog; +- PnPUtil, Device Manager, Code Integrity, and UMDF host diagnostics; +- standard-user COM access after installation and reboot; +- repair, upgrade, rollback, device restart, and full uninstall results. + +Direct distribution passes only if the package stages, the UMDF host loads it, and the complete +end-to-end harness passes without weakening Secure Boot, Memory Integrity, or certificate policy. +If clean Windows rejects the package, preserve the exact failure before selecting EV-backed +Hardware Dev Center attestation or WHCP/HLK as the required production path. diff --git a/docs/integration/windows-virtual-serial.md b/docs/integration/windows-virtual-serial.md index d67fc97..4dc3e39 100644 --- a/docs/integration/windows-virtual-serial.md +++ b/docs/integration/windows-virtual-serial.md @@ -90,4 +90,6 @@ catalog without installing that certificate on the build workstation: The resulting package is suitable only for the isolated VM procedure in `scripts/Test-UmdfEndToEnd.ps1`. Production distribution still requires the approved public -catalog-signing path and clean-system Secure Boot/Memory Integrity acceptance evidence. +catalog-signing path and clean-system Secure Boot/Memory Integrity acceptance evidence. See the +[signing decision gate](../design/virtual-serial-signing-decision.md) for the current provider +research and required acceptance record. From 7777d0124fc2b17360a4e0a2946320de96823735 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 21:17:31 -0700 Subject: [PATCH 24/39] Audit Rust dependency supply chain --- .github/workflows/ci.yml | 16 ++++++++++++++++ Cargo.lock | 14 +++++++------- deny.toml | 9 +++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 deny.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 273ebf4..323dedc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,3 +71,19 @@ jobs: - name: Build, test, and lint pure-Rust UMDF driver shell: pwsh run: .\scripts\Test-UmdfPoc.ps1 + + supply-chain: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.0 + - name: Audit application dependencies + uses: EmbarkStudios/cargo-deny-action@v2.1.1 + with: + command: check + command-arguments: advisories bans sources + - name: Audit UMDF driver dependencies + uses: EmbarkStudios/cargo-deny-action@v2.1.1 + with: + command: check + command-arguments: advisories bans sources + manifest-path: drivers/cathub-virtual-serial-umdf/Cargo.toml diff --git a/Cargo.lock b/Cargo.lock index f938364..0441b37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,7 +314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -398,9 +398,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -527,7 +527,7 @@ dependencies = [ "hyper", "libc", "pin-project-lite", - "socket2 0.6.5", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -648,7 +648,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -945,7 +945,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1119,7 +1119,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..675b30f --- /dev/null +++ b/deny.toml @@ -0,0 +1,9 @@ +[advisories] +ignore = [ + # Transitive build-time dependency of the pinned Microsoft windows-drivers-rs revision. + # RUSTSEC reports it as unmaintained, not vulnerable, and provides no safe upgrade. + { id = "RUSTSEC-2024-0436", reason = "Pinned windows-drivers-rs build dependency; remove when Microsoft replaces paste upstream" }, +] + +[sources] +allow-git = ["https://github.com/microsoft/windows-drivers-rs"] From 530e77c1875f452c970c234a6920a7bcd6cdb12c Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 21:23:53 -0700 Subject: [PATCH 25/39] Satisfy current stable provisioning lint --- crates/cathub/src/virtual_serial_provisioning.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cathub/src/virtual_serial_provisioning.rs b/crates/cathub/src/virtual_serial_provisioning.rs index 01ee3e4..3a4c0bc 100644 --- a/crates/cathub/src/virtual_serial_provisioning.rs +++ b/crates/cathub/src/virtual_serial_provisioning.rs @@ -770,7 +770,7 @@ mod platform { } } owned.sort_by(|left, right| left.stable_id.cmp(&right.stable_id)); - claims.sort_by(|left, right| com_number(&left.com_port).cmp(&com_number(&right.com_port))); + claims.sort_by_key(|claim| com_number(&claim.com_port)); Ok(SystemSnapshot { owned, claims }) } From 5d181420ecfa5f905eb12770dea2c574ee434b86 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 21:36:44 -0700 Subject: [PATCH 26/39] Capture clean-target UMDF acceptance evidence --- docs/integration/windows-virtual-serial.md | 17 + scripts/Test-UmdfEndToEnd.ps1 | 367 ++++++++++++++++++++- 2 files changed, 366 insertions(+), 18 deletions(-) diff --git a/docs/integration/windows-virtual-serial.md b/docs/integration/windows-virtual-serial.md index 4dc3e39..7d5aada 100644 --- a/docs/integration/windows-virtual-serial.md +++ b/docs/integration/windows-virtual-serial.md @@ -93,3 +93,20 @@ The resulting package is suitable only for the isolated VM procedure in catalog-signing path and clean-system Secure Boot/Memory Integrity acceptance evidence. See the [signing decision gate](../design/virtual-serial-signing-decision.md) for the current provider research and required acceptance record. + +Run the development acceptance harness only from an elevated PowerShell session inside an isolated +Hyper-V VM: + +```powershell +cd C:\CatHubUmdfTest +.\Test-UmdfEndToEnd.ps1 -IUnderstandThisInstallsATestDriver +``` + +The harness fails before installation unless Secure Boot and Memory Integrity are running and the +boot configuration has neither test-signing mode nor integrity checks disabled. Its JSON evidence +records the OS and boot policy, package manifest, catalog trust before and after importing the +ephemeral test certificate, PnP driver metadata, serial I/O and recovery cases, and System, Code +Integrity, and UMDF event logs. After a successful run it removes the endpoint, staged OEM driver +package, and test certificate and verifies the resulting CatHub inventory. Pass `-KeepInstalled` +only when retaining that isolated VM state is necessary for debugging; failed runs retain state so +the original failure can be inspected before reverting the VM checkpoint. diff --git a/scripts/Test-UmdfEndToEnd.ps1 b/scripts/Test-UmdfEndToEnd.ps1 index ff46158..1df00bb 100644 --- a/scripts/Test-UmdfEndToEnd.ps1 +++ b/scripts/Test-UmdfEndToEnd.ps1 @@ -5,7 +5,9 @@ param( [string]$DriverPackage = (Join-Path $PSScriptRoot 'driver'), [string]$CatHubExe = (Join-Path $PSScriptRoot 'cathub.exe'), - [string]$ResultsPath = (Join-Path $PSScriptRoot 'cathub-umdf-e2e.json') + [string]$ResultsPath = (Join-Path $PSScriptRoot 'cathub-umdf-e2e.json'), + + [switch]$KeepInstalled ) $ErrorActionPreference = 'Stop' @@ -22,6 +24,166 @@ function Invoke-Checked { } } +function Invoke-Captured { + param( + [Parameter(Mandatory)][string]$Command, + [Parameter(Mandatory)][string[]]$Arguments + ) + + $output = (& $Command @Arguments 2>&1 | Out-String).Trim() + return [ordered]@{ + command = "$Command $($Arguments -join ' ')" + exit_code = $LASTEXITCODE + output = $output + } +} + +function Get-SignatureEvidence { + param([Parameter(Mandatory)][string]$Path) + + $signature = Get-AuthenticodeSignature -LiteralPath $Path + return [ordered]@{ + path = $Path + status = $signature.Status.ToString() + status_message = $signature.StatusMessage + signer_subject = if ($signature.SignerCertificate) { + $signature.SignerCertificate.Subject + } else { $null } + signer_thumbprint = if ($signature.SignerCertificate) { + $signature.SignerCertificate.Thumbprint + } else { $null } + timestamp_subject = if ($signature.TimeStamperCertificate) { + $signature.TimeStamperCertificate.Subject + } else { $null } + } +} + +function Get-PackageIntegrityEvidence { + param( + [Parameter(Mandatory)][string]$PackageRoot, + [Parameter(Mandatory)]$Manifest + ) + + $files = foreach ($expected in $Manifest.files) { + $path = Join-Path $PackageRoot $expected.name + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + [ordered]@{ + name = $expected.name + exists = $false + passed = $false + } + continue + } + + $item = Get-Item -LiteralPath $path + $actualHash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash + [ordered]@{ + name = $expected.name + exists = $true + expected_length = [long]$expected.length + actual_length = [long]$item.Length + expected_sha256 = $expected.sha256 + actual_sha256 = $actualHash + passed = ( + [long]$item.Length -eq [long]$expected.length -and + $actualHash -eq $expected.sha256 + ) + } + } + + return [ordered]@{ + passed = @($files | Where-Object { -not $_.passed }).Count -eq 0 + files = @($files) + } +} + +function Get-SecurityEvidence { + $secureBoot = $null + $secureBootError = $null + try { + $secureBoot = [bool](Confirm-SecureBootUEFI -ErrorAction Stop) + } + catch { + $secureBootError = $_.Exception.Message + } + + $deviceGuard = $null + $deviceGuardError = $null + try { + $guard = Get-CimInstance -Namespace 'root\Microsoft\Windows\DeviceGuard' ` + -ClassName Win32_DeviceGuard -ErrorAction Stop + $deviceGuard = [ordered]@{ + virtualization_based_security_status = $guard.VirtualizationBasedSecurityStatus + security_services_configured = @($guard.SecurityServicesConfigured) + security_services_running = @($guard.SecurityServicesRunning) + available_security_properties = @($guard.AvailableSecurityProperties) + } + } + catch { + $deviceGuardError = $_.Exception.Message + } + + $bootPolicy = Invoke-Captured bcdedit.exe @('/enum', '{current}') + return [ordered]@{ + secure_boot_enabled = $secureBoot + secure_boot_error = $secureBootError + device_guard = $deviceGuard + device_guard_error = $deviceGuardError + testsigning_enabled = [bool]($bootPolicy.output -match '(?im)^testsigning\s+Yes\s*$') + no_integrity_checks_enabled = [bool]( + $bootPolicy.output -match '(?im)^nointegritychecks\s+Yes\s*$' + ) + boot_policy = $bootPolicy + } +} + +function Get-PnpEvidence { + param([Parameter(Mandatory)][string]$InstanceId) + + $device = Get-PnpDevice -InstanceId $InstanceId -ErrorAction Stop + $properties = [ordered]@{} + foreach ($key in @( + 'DEVPKEY_Device_DriverInfPath', + 'DEVPKEY_Device_DriverVersion', + 'DEVPKEY_Device_DriverProvider', + 'DEVPKEY_Device_ProblemCode', + 'DEVPKEY_Device_Service' + )) { + $property = Get-PnpDeviceProperty -InstanceId $InstanceId -KeyName $key ` + -ErrorAction SilentlyContinue + if ($property) { + $properties[$key] = $property.Data + } + } + + return [ordered]@{ + instance_id = $device.InstanceId + class = $device.Class + friendly_name = $device.FriendlyName + status = $device.Status + problem = $device.Problem + properties = $properties + } +} + +function Get-EventEvidence { + param( + [Parameter(Mandatory)][string]$LogName, + [Parameter(Mandatory)][DateTime]$StartTime + ) + + try { + return @( + Get-WinEvent -FilterHashtable @{ LogName = $LogName; StartTime = $StartTime } ` + -ErrorAction Stop | + Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message + ) + } + catch { + return @([ordered]@{ collection_error = $_.Exception.Message }) + } +} + function Find-CatHubDevice { $deadline = [DateTime]::UtcNow.AddSeconds(20) do { @@ -82,7 +244,17 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra $infPath = Join-Path $DriverPackage 'cathub_virtual_serial_umdf.inf' $certificatePath = Join-Path $DriverPackage 'cathub_umdf_test.cer' -foreach ($path in @($infPath, $certificatePath, $CatHubExe)) { +$catalogPath = Join-Path $DriverPackage 'cathub_virtual_serial_umdf.cat' +$driverDllPath = Join-Path $DriverPackage 'cathub_virtual_serial_umdf.dll' +$manifestPath = Join-Path $DriverPackage 'package-manifest.json' +foreach ($path in @( + $infPath, + $certificatePath, + $catalogPath, + $driverDllPath, + $manifestPath, + $CatHubExe +)) { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Required test input is missing: $path" } @@ -110,29 +282,99 @@ perms = ["read", "frequency_write", "write", "ptt", "config_write"] $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( $certificatePath ) -Invoke-Checked certutil @('-f', '-addstore', 'Root', $certificatePath) -Invoke-Checked certutil @('-f', '-addstore', 'TrustedPublisher', $certificatePath) -Invoke-Checked $CatHubExe @( - '--config', $configPath, - 'virtual-serial', 'apply', - '--inf', $infPath, - '--format', 'json' -) - -$device = Find-CatHubDevice -$portName = $device.Port +$testStart = [DateTime]::UtcNow +$operatingSystem = Get-CimInstance Win32_OperatingSystem +$packageManifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +$device = $null +$portName = $null +$publishedInf = $null $process = $null $serial = $null +$failure = $null $results = [ordered]@{ - timestamp_utc = [DateTime]::UtcNow.ToString('o') + timestamp_utc = $testStart.ToString('o') machine = $env:COMPUTERNAME - port = $portName - device_instance_id = $device.InstanceId + os = [ordered]@{ + caption = $operatingSystem.Caption + version = $operatingSystem.Version + build_number = $operatingSystem.BuildNumber + } + security = Get-SecurityEvidence + package_manifest = $packageManifest + package_integrity = Get-PackageIntegrityEvidence ` + -PackageRoot $DriverPackage -Manifest $packageManifest + cathub_exe_sha256 = (Get-FileHash -LiteralPath $CatHubExe -Algorithm SHA256).Hash + harness_sha256 = (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash + signatures_before_trust = [ordered]@{ + catalog = Get-SignatureEvidence -Path $catalogPath + driver_dll = Get-SignatureEvidence -Path $driverDllPath + cathub_exe = Get-SignatureEvidence -Path $CatHubExe + } + port = $null + device_instance_id = $null driver_certificate = $certificate.Thumbprint + keep_installed = [bool]$KeepInstalled cases = @() } try { + if (-not $results.package_integrity.passed) { + throw 'One or more staged driver files do not match package-manifest.json.' + } + if ($results.security.secure_boot_enabled -ne $true) { + throw 'Secure Boot is not enabled in the isolated Windows test target.' + } + if ($results.security.testsigning_enabled) { + throw 'Windows test-signing mode is enabled; the acceptance target must use normal policy.' + } + if ($results.security.no_integrity_checks_enabled) { + throw 'Windows integrity checks are disabled; the acceptance target must use normal policy.' + } + if ( + -not $results.security.device_guard -or + 2 -notin @($results.security.device_guard.security_services_running) + ) { + throw 'Memory Integrity (HVCI) is not reported as running on the isolated target.' + } + + $results.certificate_root_install = Invoke-Captured certutil @( + '-f', '-addstore', 'Root', $certificatePath + ) + if ($results.certificate_root_install.exit_code -ne 0) { + throw 'Installing the test certificate in LocalMachine\Root failed.' + } + $results.certificate_publisher_install = Invoke-Captured certutil @( + '-f', '-addstore', 'TrustedPublisher', $certificatePath + ) + if ($results.certificate_publisher_install.exit_code -ne 0) { + throw 'Installing the test certificate in LocalMachine\TrustedPublisher failed.' + } + $results.signatures_after_trust = [ordered]@{ + catalog = Get-SignatureEvidence -Path $catalogPath + driver_dll = Get-SignatureEvidence -Path $driverDllPath + cathub_exe = Get-SignatureEvidence -Path $CatHubExe + } + if ($results.signatures_after_trust.catalog.status -ne 'Valid') { + throw "The installed catalog signature is not valid: $($results.signatures_after_trust.catalog.status_message)" + } + + $results.apply = Invoke-Captured $CatHubExe @( + '--config', $configPath, + 'virtual-serial', 'apply', + '--inf', $infPath, + '--format', 'json' + ) + if ($results.apply.exit_code -ne 0) { + throw "CatHub virtual-serial apply failed: $($results.apply.output)" + } + + $device = Find-CatHubDevice + $portName = $device.Port + $results.port = $portName + $results.device_instance_id = $device.InstanceId + $results.pnp_after_install = Get-PnpEvidence -InstanceId $device.InstanceId + $publishedInf = $results.pnp_after_install.properties['DEVPKEY_Device_DriverInfPath'] + $process = Start-Process -FilePath $CatHubExe ` -ArgumentList @('--config', $configPath) ` -RedirectStandardOutput $stdoutPath ` @@ -308,6 +550,7 @@ try { if ($restartedDevice.InstanceId -ne $device.InstanceId) { throw "Device restart changed instance ID to '$($restartedDevice.InstanceId)'." } + $results.pnp_after_restart = Get-PnpEvidence -InstanceId $restartedDevice.InstanceId $reconnectDeadline = [DateTime]::UtcNow.AddSeconds(20) $deviceRestartId = $null @@ -352,7 +595,7 @@ try { catch { $results.passed = $false $results.error = $_.Exception.Message - throw + $failure = $_ } finally { if ($serial) { @@ -365,14 +608,102 @@ finally { Stop-Process -Id $process.Id -Force $process.WaitForExit() } + + if ($results.passed -and -not $KeepInstalled) { + try { + $cleanup = [ordered]@{} + $cleanup.remove_endpoint = Invoke-Captured $CatHubExe @( + '--config', $configPath, + 'virtual-serial', 'remove', + '--endpoint', 'cathub-default', + '--format', 'json' + ) + if ($cleanup.remove_endpoint.exit_code -ne 0) { + throw "Removing the CatHub endpoint failed: $($cleanup.remove_endpoint.output)" + } + + Start-Sleep -Seconds 1 + $remainingDevice = Get-PnpDevice -PresentOnly -ErrorAction SilentlyContinue | + Where-Object InstanceId -EQ $device.InstanceId + if ($remainingDevice) { + throw "CatHub device '$($device.InstanceId)' is still present after removal." + } + + if ($publishedInf -notmatch '^oem\d+\.inf$') { + throw "Could not determine the staged OEM INF name (reported '$publishedInf')." + } + $cleanup.delete_driver_package = Invoke-Captured pnputil.exe @( + '/delete-driver', $publishedInf, '/uninstall', '/force' + ) + if ($cleanup.delete_driver_package.exit_code -ne 0) { + throw "Deleting driver package '$publishedInf' failed: $($cleanup.delete_driver_package.output)" + } + + $cleanup.delete_trusted_publisher_certificate = Invoke-Captured certutil.exe @( + '-delstore', 'TrustedPublisher', $certificate.Thumbprint + ) + if ($cleanup.delete_trusted_publisher_certificate.exit_code -ne 0) { + throw 'Removing the test certificate from TrustedPublisher failed.' + } + $cleanup.delete_root_certificate = Invoke-Captured certutil.exe @( + '-delstore', 'Root', $certificate.Thumbprint + ) + if ($cleanup.delete_root_certificate.exit_code -ne 0) { + throw 'Removing the test certificate from Root failed.' + } + + $cleanup.status_after_remove = Invoke-Captured $CatHubExe @( + 'virtual-serial', 'status', '--format', 'json' + ) + if ($cleanup.status_after_remove.exit_code -ne 0) { + throw "Post-removal status failed: $($cleanup.status_after_remove.output)" + } + $cleanup.passed = $true + $results.cleanup = $cleanup + $results.cases += [ordered]@{ + name = 'remove_device_driver_and_test_trust' + passed = $true + published_inf = $publishedInf + } + } + catch { + $results.passed = $false + $results.error = "Cleanup verification failed: $($_.Exception.Message)" + $results.cleanup = if ($cleanup) { $cleanup } else { [ordered]@{} } + $results.cleanup.passed = $false + $results.cleanup.error = $_.Exception.Message + $failure = $_ + } + } + elseif ($results.passed) { + $results.cleanup = [ordered]@{ + passed = $null + skipped = $true + reason = '-KeepInstalled was specified' + } + } + $results.cathub_stdout = if (Test-Path -LiteralPath $stdoutPath) { Get-Content -LiteralPath $stdoutPath -Raw } else { '' } $results.cathub_stderr = if (Test-Path -LiteralPath $stderrPath) { Get-Content -LiteralPath $stderrPath -Raw } else { '' } - $results | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $ResultsPath -Encoding utf8 + $results.events = [ordered]@{ + system = Get-EventEvidence -LogName 'System' -StartTime $testStart + code_integrity = Get-EventEvidence ` + -LogName 'Microsoft-Windows-CodeIntegrity/Operational' -StartTime $testStart + umdf = Get-EventEvidence ` + -LogName 'Microsoft-Windows-DriverFrameworks-UserMode/Operational' ` + -StartTime $testStart + } + $results.completed_at_utc = [DateTime]::UtcNow.ToString('o') + $results.duration_ms = [long]([DateTime]::UtcNow - $testStart).TotalMilliseconds + $results | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $ResultsPath -Encoding utf8 } +if ($failure) { + throw $failure +} Write-Host "CatHub UMDF end-to-end test passed on $portName." Write-Host "Results: $ResultsPath" From 9bfe9c579723ceaadef67e30d9c36373807a3975 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 21:53:24 -0700 Subject: [PATCH 27/39] Exercise managed UMDF serial APIs end to end --- crates/cathub-virtual-serial/README.md | 15 ++ .../src/bin/serial-conformance.rs | 27 ++- .../src/conformance/mod.rs | 37 +++- .../src/conformance/windows.rs | 177 +++++++++++++++--- crates/cathub-virtual-serial/src/lib.rs | 4 + crates/cathub/src/lib.rs | 93 ++++++++- docs/integration/windows-virtual-serial.md | 12 +- scripts/Test-UmdfEndToEnd.ps1 | 69 ++++++- 8 files changed, 392 insertions(+), 42 deletions(-) diff --git a/crates/cathub-virtual-serial/README.md b/crates/cathub-virtual-serial/README.md index 58ed345..33c7dfa 100644 --- a/crates/cathub-virtual-serial/README.md +++ b/crates/cathub-virtual-serial/README.md @@ -23,3 +23,18 @@ cargo run -p cathub-virtual-serial --bin serial-conformance -- run ` ``` Do not use a physical radio or a physical WinKeyer for this test. + +For an installed CatHub-owned UMDF endpoint, start CatHub's hidden loopback-only test peer and use +the private-channel conformance mode instead of creating a second COM port: + +```powershell +cathub virtual-serial test-peer --endpoint cathub-default ` + --kind cat --listen 127.0.0.1:39116 + +serial-conformance run --application-port COM91 ` + --peer-tcp 127.0.0.1:39116 --profile n1mm-radio ` + --output artifacts\serial-conformance\managed-n1mm-radio.json +``` + +The test peer binds only the explicitly supplied address; the isolated-target harness always uses +IPv4 loopback and terminates it before starting the production daemon path. diff --git a/crates/cathub-virtual-serial/src/bin/serial-conformance.rs b/crates/cathub-virtual-serial/src/bin/serial-conformance.rs index 03413c0..36751d2 100644 --- a/crates/cathub-virtual-serial/src/bin/serial-conformance.rs +++ b/crates/cathub-virtual-serial/src/bin/serial-conformance.rs @@ -1,8 +1,9 @@ //! Windows serial API conformance command. +use std::net::SocketAddr; use std::path::PathBuf; -use cathub_virtual_serial::conformance::{profiles, run, ConformanceError}; +use cathub_virtual_serial::conformance::{profiles, run, run_with_tcp_peer, ConformanceError}; use clap::{Parser, Subcommand}; #[derive(Debug, Parser)] @@ -26,8 +27,19 @@ enum Command { #[arg(long)] application_port: String, /// Paired COM port that the harness uses as the transport peer. - #[arg(long)] - peer_port: String, + #[arg( + long, + required_unless_present = "peer_tcp", + conflicts_with = "peer_tcp" + )] + peer_port: Option, + /// TCP bridge to a CatHub-managed endpoint's private transport peer. + #[arg( + long, + required_unless_present = "peer_port", + conflicts_with = "peer_port" + )] + peer_tcp: Option, /// Stable profile name from the profiles command. #[arg(long)] profile: String, @@ -46,10 +58,17 @@ fn main() -> Result<(), ConformanceError> { Command::Run { application_port, peer_port, + peer_tcp, profile, output, } => { - let report = run(&profile, &application_port, &peer_port)?; + let report = match (peer_port, peer_tcp) { + (Some(peer_port), None) => run(&profile, &application_port, &peer_port)?, + (None, Some(peer_address)) => { + run_with_tcp_peer(&profile, &application_port, peer_address)? + } + _ => unreachable!("clap requires exactly one peer transport"), + }; println!("{}", serde_json::to_string_pretty(&report)?); if let Some(path) = output { report.write_json(&path)?; diff --git a/crates/cathub-virtual-serial/src/conformance/mod.rs b/crates/cathub-virtual-serial/src/conformance/mod.rs index e6bf7f2..baea300 100644 --- a/crates/cathub-virtual-serial/src/conformance/mod.rs +++ b/crates/cathub-virtual-serial/src/conformance/mod.rs @@ -5,6 +5,7 @@ mod profiles; #[cfg(windows)] mod windows; +use std::net::SocketAddr; use std::path::Path; use serde::{Deserialize, Serialize}; @@ -175,7 +176,7 @@ pub fn run( #[cfg(windows)] { - windows::run(profile, application_port, peer_port) + windows::run_serial_pair(profile, application_port, peer_port) } #[cfg(not(windows))] @@ -185,6 +186,40 @@ pub fn run( } } +/// Run one profile against a CatHub-managed endpoint whose private peer is exposed over TCP. +/// +/// The TCP bridge is a test-only stand-in for `cathub.exe`'s private UMDF adapter. The +/// application-facing side remains the real Windows COM device and exercises the same native +/// serial APIs as the two-port harness. +/// +/// # Errors +/// +/// Returns an error if the profile is unknown, the COM port or peer cannot open, or the host is +/// not Windows. +pub fn run_with_tcp_peer( + profile_name: &str, + application_port: &str, + peer_address: SocketAddr, +) -> Result { + let profile = find_profile(profile_name) + .ok_or_else(|| ConformanceError::UnknownProfile(profile_name.to_string()))?; + + #[cfg(windows)] + { + Ok(windows::run_tcp_peer( + profile, + application_port, + peer_address, + )) + } + + #[cfg(not(windows))] + { + let _ = (profile, application_port, peer_address); + Err(ConformanceError::UnsupportedPlatform) + } +} + /// Conformance harness errors. #[derive(Debug, Error)] pub enum ConformanceError { diff --git a/crates/cathub-virtual-serial/src/conformance/windows.rs b/crates/cathub-virtual-serial/src/conformance/windows.rs index d6f09c9..e2a9711 100644 --- a/crates/cathub-virtual-serial/src/conformance/windows.rs +++ b/crates/cathub-virtual-serial/src/conformance/windows.rs @@ -1,6 +1,8 @@ #![allow(unsafe_code, clippy::borrow_as_ptr)] +use std::io::{Read, Write}; use std::mem::{size_of, zeroed}; +use std::net::{SocketAddr, TcpStream}; use std::ptr::{null, null_mut}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -24,11 +26,27 @@ use super::{ ApplicationProfile, CaseId, CaseResult, CaseStatus, ConformanceError, ConformanceReport, ProfileCase, }; +use crate::TEST_PEER_READY; const IO_WAIT_MS: u32 = 2_000; const TEST_DATA: &[u8] = b"CatHub serial conformance"; -pub(super) fn run( +#[derive(Clone, Copy)] +enum PeerTarget<'a> { + Serial(&'a str), + Tcp(SocketAddr), +} + +impl PeerTarget<'_> { + fn report_name(self) -> String { + match self { + Self::Serial(port) => port.to_string(), + Self::Tcp(address) => format!("tcp://{address}"), + } + } +} + +pub(super) fn run_serial_pair( profile: &ApplicationProfile, application_port: &str, peer_port: &str, @@ -38,44 +56,65 @@ pub(super) fn run( "application and peer ports must be different".to_string(), )); } + Ok(run( + profile, + application_port, + PeerTarget::Serial(peer_port), + )) +} + +pub(super) fn run_tcp_peer( + profile: &ApplicationProfile, + application_port: &str, + peer_address: SocketAddr, +) -> ConformanceReport { + run(profile, application_port, PeerTarget::Tcp(peer_address)) +} + +fn run( + profile: &ApplicationProfile, + application_port: &str, + peer_target: PeerTarget<'_>, +) -> ConformanceReport { + let peer_name = peer_target.report_name(); let results = profile .cases .iter() - .map(|case| run_case(*case, profile, application_port, peer_port)) + .map(|case| run_case(*case, profile, application_port, peer_target)) .collect(); let seconds = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); - Ok(ConformanceReport { + ConformanceReport { schema_version: 1, generated_at_utc: format!("unix:{seconds}"), profile: profile.name.to_string(), application_port: application_port.to_string(), - peer_port: peer_port.to_string(), + peer_port: peer_name, operating_system: "Windows".to_string(), results, - }) + } } fn run_case( profile_case: ProfileCase, profile: &ApplicationProfile, application_port: &str, - peer_port: &str, + peer_target: PeerTarget<'_>, ) -> CaseResult { let result = match profile_case.id { - CaseId::SynchronousIo => synchronous_io(application_port, peer_port), - CaseId::OverlappedIo => overlapped_io(application_port, peer_port), + CaseId::SynchronousIo => synchronous_io(application_port, peer_target), + CaseId::OverlappedIo => overlapped_io(application_port, peer_target), CaseId::CancelPendingRead => cancel_pending_read(application_port), CaseId::ReadTimeout => read_timeout(application_port), - CaseId::PurgeReceive => purge_receive(application_port, peer_port), - CaseId::WaitCommEvent => wait_comm_event(application_port, peer_port), + CaseId::PurgeReceive => purge_receive(application_port, peer_target), + CaseId::WaitCommEvent => wait_comm_event(application_port, peer_target), CaseId::SerialConfiguration => serial_configuration(application_port, profile), CaseId::ModemControl => modem_control(application_port), - CaseId::QueueStatus => queue_status(application_port, peer_port), + CaseId::QueueStatus => queue_status(application_port, peer_target), }; match result { @@ -99,33 +138,40 @@ fn run_case( } } -fn synchronous_io(application_port: &str, peer_port: &str) -> Result { +fn synchronous_io( + application_port: &str, + peer_target: PeerTarget<'_>, +) -> Result { let application = Port::open(application_port, false)?; - let peer = Port::open(peer_port, false)?; + let mut peer = Peer::open(peer_target)?; set_timeout(application.handle(), 500)?; - set_timeout(peer.handle(), 500)?; + peer.set_timeout(Duration::from_millis(500))?; write_sync(application.handle(), TEST_DATA)?; - let received = read_sync(peer.handle(), TEST_DATA.len())?; + let received = peer.read_exact(TEST_DATA.len())?; require_equal("synchronous application write", &received, TEST_DATA)?; let reply = b"CatHub synchronous reply"; - write_sync(peer.handle(), reply)?; + peer.write_all(reply)?; let received = read_sync(application.handle(), reply.len())?; require_equal("synchronous application read", &received, reply)?; Ok("blocking reads and writes transferred bytes in both directions".to_string()) } -fn overlapped_io(application_port: &str, peer_port: &str) -> Result { +fn overlapped_io( + application_port: &str, + peer_target: PeerTarget<'_>, +) -> Result { let application = Port::open(application_port, true)?; - let peer = Port::open(peer_port, true)?; + let mut peer = Peer::open(peer_target)?; + peer.set_timeout(Duration::from_millis(IO_WAIT_MS.into()))?; write_overlapped(application.handle(), TEST_DATA)?; - let received = read_overlapped(peer.handle(), TEST_DATA.len())?; + let received = peer.read_exact(TEST_DATA.len())?; require_equal("overlapped application write", &received, TEST_DATA)?; let reply = b"CatHub overlapped reply"; - write_overlapped(peer.handle(), reply)?; + peer.write_all(reply)?; let received = read_overlapped(application.handle(), reply.len())?; require_equal("overlapped application read", &received, reply)?; Ok("overlapped reads and writes transferred bytes in both directions".to_string()) @@ -195,11 +241,14 @@ fn read_timeout(application_port: &str) -> Result { )) } -fn purge_receive(application_port: &str, peer_port: &str) -> Result { +fn purge_receive( + application_port: &str, + peer_target: PeerTarget<'_>, +) -> Result { let application = Port::open(application_port, false)?; - let peer = Port::open(peer_port, false)?; + let mut peer = Peer::open(peer_target)?; set_timeout(application.handle(), 500)?; - write_sync(peer.handle(), TEST_DATA)?; + peer.write_all(TEST_DATA)?; std::thread::sleep(Duration::from_millis(50)); let before = queue_depth(application.handle())?; if before == 0 { @@ -219,9 +268,12 @@ fn purge_receive(application_port: &str, peer_port: &str) -> Result Result { +fn wait_comm_event( + application_port: &str, + peer_target: PeerTarget<'_>, +) -> Result { let application = Port::open(application_port, true)?; - let peer = Port::open(peer_port, true)?; + let mut peer = Peer::open(peer_target)?; if unsafe { SetCommMask(application.handle(), EV_RXCHAR) } == 0 { return Err(last_error("SetCommMask")); } @@ -236,7 +288,7 @@ fn wait_comm_event(application_port: &str, peer_port: &str) -> Result Result { Ok("DTR, RTS, and break control requests completed".to_string()) } -fn queue_status(application_port: &str, peer_port: &str) -> Result { +fn queue_status( + application_port: &str, + peer_target: PeerTarget<'_>, +) -> Result { let application = Port::open(application_port, false)?; - let peer = Port::open(peer_port, false)?; + let mut peer = Peer::open(peer_target)?; set_timeout(application.handle(), 500)?; - write_sync(peer.handle(), TEST_DATA)?; + peer.write_all(TEST_DATA)?; std::thread::sleep(Duration::from_millis(50)); let depth = queue_depth(application.handle())?; if depth < u32::try_from(TEST_DATA.len()).unwrap_or(u32::MAX) { @@ -543,6 +598,70 @@ impl Drop for Port { } } +enum Peer { + Serial(Port), + Tcp(TcpStream), +} + +impl Peer { + fn open(target: PeerTarget<'_>) -> Result { + match target { + PeerTarget::Serial(port) => Ok(Self::Serial(Port::open(port, false)?)), + PeerTarget::Tcp(address) => { + let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(2))?; + stream.set_nodelay(true)?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut ready = [0_u8; TEST_PEER_READY.len()]; + Read::read_exact(&mut stream, &mut ready)?; + if ready != TEST_PEER_READY { + return Err(ConformanceError::InvalidResult( + "managed test peer returned an invalid readiness preface".to_string(), + )); + } + Ok(Self::Tcp(stream)) + } + } + } + + fn set_timeout(&self, timeout: Duration) -> Result<(), ConformanceError> { + match self { + Self::Serial(port) => { + let milliseconds = u32::try_from(timeout.as_millis()).map_err(|_| { + ConformanceError::InvalidResult("peer timeout exceeds u32".to_string()) + })?; + set_timeout(port.handle(), milliseconds) + } + Self::Tcp(stream) => { + stream.set_read_timeout(Some(timeout))?; + stream.set_write_timeout(Some(timeout))?; + Ok(()) + } + } + } + + fn read_exact(&mut self, length: usize) -> Result, ConformanceError> { + match self { + Self::Serial(port) => read_sync(port.handle(), length), + Self::Tcp(stream) => { + let mut bytes = vec![0_u8; length]; + Read::read_exact(stream, &mut bytes)?; + Ok(bytes) + } + } + } + + fn write_all(&mut self, bytes: &[u8]) -> Result<(), ConformanceError> { + match self { + Self::Serial(port) => write_sync(port.handle(), bytes), + Self::Tcp(stream) => { + Write::write_all(stream, bytes)?; + Write::flush(stream)?; + Ok(()) + } + } + } +} + struct Event(HANDLE); impl Event { diff --git a/crates/cathub-virtual-serial/src/lib.rs b/crates/cathub-virtual-serial/src/lib.rs index 29c9ba4..9be773b 100644 --- a/crates/cathub-virtual-serial/src/lib.rs +++ b/crates/cathub-virtual-serial/src/lib.rs @@ -9,3 +9,7 @@ pub mod conformance; pub mod daemon; pub mod protocol; + +/// Readiness preface used by the loopback-only managed serial conformance bridge. +#[doc(hidden)] +pub const TEST_PEER_READY: &[u8] = b"CHVS-TEST-READY\n"; diff --git a/crates/cathub/src/lib.rs b/crates/cathub/src/lib.rs index 28d2af8..55b1392 100644 --- a/crates/cathub/src/lib.rs +++ b/crates/cathub/src/lib.rs @@ -45,7 +45,13 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; +#[cfg(windows)] +use cathub_virtual_serial::TEST_PEER_READY; use clap::{Parser, Subcommand, ValueEnum}; +#[cfg(windows)] +use tokio::io::AsyncWriteExt; +#[cfg(windows)] +use tokio::net::TcpListener; use tokio::net::TcpStream; use tracing_appender::non_blocking::WorkerGuard; @@ -174,6 +180,37 @@ pub enum VirtualSerialCommand { #[arg(long, value_enum, default_value_t = OutputFormat::Text)] format: OutputFormat, }, + /// Expose the private managed transport over loopback TCP for the serial conformance harness. + #[command(hide = true)] + TestPeer { + /// Stable endpoint ID to attach to. + #[arg(long, default_value = "cathub-default")] + endpoint: String, + /// Provisioned endpoint kind. + #[arg(long, value_enum, default_value_t = ManagedEndpointKind::Cat)] + kind: ManagedEndpointKind, + /// Loopback address used by the conformance process. + #[arg(long, default_value = "127.0.0.1:39116")] + listen: SocketAddr, + }, +} + +/// Test-only managed endpoint kind used by the serial conformance bridge. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum ManagedEndpointKind { + /// CAT radio endpoint. + Cat, + /// WinKeyer endpoint. + Winkeyer, +} + +impl ManagedEndpointKind { + const fn code(self) -> u16 { + match self { + Self::Cat => 1, + Self::Winkeyer => 2, + } + } } /// CatHub configuration commands. @@ -294,6 +331,7 @@ async fn open_radio_tcp(radio: &RadioConfig) -> std::io::Result { pub async fn run(cli: Cli) -> Result<(), CatHubError> { if let Some(command) = cli.command { return run_command(command, cli.config, cli.section.as_deref()) + .await .map_err(CatHubError::Config); } let path = cli @@ -664,7 +702,7 @@ pub async fn run(cli: Cli) -> Result<(), CatHubError> { Ok(()) } -fn run_command( +async fn run_command( command: Command, config_path: Option, section: Option<&str>, @@ -726,12 +764,12 @@ fn run_command( } }, Command::VirtualSerial { command } => { - run_virtual_serial_command(command, config_path.as_deref(), section) + run_virtual_serial_command(command, config_path.as_deref(), section).await } } } -fn run_virtual_serial_command( +async fn run_virtual_serial_command( command: VirtualSerialCommand, config_path: Option<&std::path::Path>, section: Option<&str>, @@ -779,9 +817,58 @@ fn run_virtual_serial_command( let report = provisioning::remove(&endpoints).map_err(error::ConfigError::Invalid)?; print_report(&report, format) } + VirtualSerialCommand::TestPeer { + endpoint, + kind, + listen, + } => run_managed_test_peer(&endpoint, kind.code(), listen).await, + } +} + +#[cfg(windows)] +async fn run_managed_test_peer( + stable_id: &str, + expected_kind: u16, + listen: SocketAddr, +) -> Result<(), error::ConfigError> { + if !listen.ip().is_loopback() { + return Err(error::ConfigError::Invalid( + "managed serial test peer must listen on a loopback address".to_string(), + )); + } + let listener = TcpListener::bind(listen).await?; + println!( + "managed virtual serial test peer listening on {}", + listener.local_addr()? + ); + + loop { + let (mut socket, peer) = listener.accept().await?; + tracing::debug!(%peer, %stable_id, "serial conformance peer connected"); + let mut managed = managed_virtual_serial::open(stable_id, expected_kind) + .await + .map_err(error::ConfigError::Io)?; + if let Err(error) = socket.write_all(TEST_PEER_READY).await { + tracing::warn!(%peer, %error, "serial conformance readiness write failed"); + continue; + } + if let Err(error) = tokio::io::copy_bidirectional(&mut socket, &mut managed).await { + tracing::warn!(%peer, %error, "serial conformance peer bridge failed"); + } } } +#[cfg(not(windows))] +fn run_managed_test_peer( + _stable_id: &str, + _expected_kind: u16, + _listen: SocketAddr, +) -> std::future::Ready> { + std::future::ready(Err(error::ConfigError::Invalid( + "managed virtual serial test peer requires Windows".to_string(), + ))) +} + /// Open the physical WinKeyer using the protocol-mandated 8-N-2 framing. fn open_winkeyer_serial(port_name: &str, baud: u32) -> std::io::Result { serial2_tokio::SerialPort::open(port_name, move |mut settings: serial2_tokio::Settings| { diff --git a/docs/integration/windows-virtual-serial.md b/docs/integration/windows-virtual-serial.md index 7d5aada..a21c0a7 100644 --- a/docs/integration/windows-virtual-serial.md +++ b/docs/integration/windows-virtual-serial.md @@ -106,7 +106,11 @@ The harness fails before installation unless Secure Boot and Memory Integrity ar boot configuration has neither test-signing mode nor integrity checks disabled. Its JSON evidence records the OS and boot policy, package manifest, catalog trust before and after importing the ephemeral test certificate, PnP driver metadata, serial I/O and recovery cases, and System, Code -Integrity, and UMDF event logs. After a successful run it removes the endpoint, staged OEM driver -package, and test certificate and verifies the resulting CatHub inventory. Pass `-KeepInstalled` -only when retaining that isolated VM state is necessary for debugging; failed runs retain state so -the original failure can be inspected before reverting the VM checkpoint. +Integrity, and UMDF event logs. Before starting the real CatHub daemon it also runs the native +Win32 conformance suite through a loopback-only bridge to the private CHVS channel. That covers +synchronous and overlapped I/O, pending-read cancellation, read timeouts, purge, `WaitCommEvent`, +queue status, modem controls, and both CAT 8-N-1 and WinKeyer 8-N-2 line formats without recreating +a second COM port. After a successful run it removes the endpoint, staged OEM driver package, and +test certificate and verifies the resulting CatHub inventory. Pass `-KeepInstalled` only when +retaining that isolated VM state is necessary for debugging; failed runs retain state so the +original failure can be inspected before reverting the VM checkpoint. diff --git a/scripts/Test-UmdfEndToEnd.ps1 b/scripts/Test-UmdfEndToEnd.ps1 index 1df00bb..0a229df 100644 --- a/scripts/Test-UmdfEndToEnd.ps1 +++ b/scripts/Test-UmdfEndToEnd.ps1 @@ -5,6 +5,7 @@ param( [string]$DriverPackage = (Join-Path $PSScriptRoot 'driver'), [string]$CatHubExe = (Join-Path $PSScriptRoot 'cathub.exe'), + [string]$ConformanceExe = (Join-Path $PSScriptRoot 'serial-conformance.exe'), [string]$ResultsPath = (Join-Path $PSScriptRoot 'cathub-umdf-e2e.json'), [switch]$KeepInstalled @@ -253,7 +254,8 @@ foreach ($path in @( $catalogPath, $driverDllPath, $manifestPath, - $CatHubExe + $CatHubExe, + $ConformanceExe )) { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Required test input is missing: $path" @@ -264,6 +266,8 @@ $workRoot = Join-Path $env:TEMP 'cathub-umdf-e2e' $configPath = Join-Path $workRoot 'cathub.toml' $stdoutPath = Join-Path $workRoot 'cathub.stdout.log' $stderrPath = Join-Path $workRoot 'cathub.stderr.log' +$peerStdoutPath = Join-Path $workRoot 'test-peer.stdout.log' +$peerStderrPath = Join-Path $workRoot 'test-peer.stderr.log' $null = New-Item -ItemType Directory -Path $workRoot -Force @' @@ -289,6 +293,7 @@ $device = $null $portName = $null $publishedInf = $null $process = $null +$peerProcess = $null $serial = $null $failure = $null $results = [ordered]@{ @@ -304,6 +309,7 @@ $results = [ordered]@{ package_integrity = Get-PackageIntegrityEvidence ` -PackageRoot $DriverPackage -Manifest $packageManifest cathub_exe_sha256 = (Get-FileHash -LiteralPath $CatHubExe -Algorithm SHA256).Hash + conformance_exe_sha256 = (Get-FileHash -LiteralPath $ConformanceExe -Algorithm SHA256).Hash harness_sha256 = (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash signatures_before_trust = [ordered]@{ catalog = Get-SignatureEvidence -Path $catalogPath @@ -375,6 +381,57 @@ try { $results.pnp_after_install = Get-PnpEvidence -InstanceId $device.InstanceId $publishedInf = $results.pnp_after_install.properties['DEVPKEY_Device_DriverInfPath'] + $portProbe = [System.Net.Sockets.TcpListener]::new( + [System.Net.IPAddress]::Loopback, + 0 + ) + $portProbe.Start() + $peerPort = ([System.Net.IPEndPoint]$portProbe.LocalEndpoint).Port + $portProbe.Stop() + $peerAddress = "127.0.0.1:$peerPort" + $peerProcess = Start-Process -FilePath $CatHubExe ` + -ArgumentList @( + 'virtual-serial', 'test-peer', + '--endpoint', 'cathub-default', + '--kind', 'cat', + '--listen', $peerAddress + ) ` + -RedirectStandardOutput $peerStdoutPath ` + -RedirectStandardError $peerStderrPath ` + -PassThru + Start-Sleep -Seconds 1 + if ($peerProcess.HasExited) { + throw "The managed serial test peer exited with code $($peerProcess.ExitCode)." + } + + $results.serial_conformance = [ordered]@{} + foreach ($profile in @('n1mm-radio', 'n1mm-winkeyer')) { + $reportPath = Join-Path $workRoot "serial-conformance-$profile.json" + $run = Invoke-Captured $ConformanceExe @( + 'run', + '--application-port', $portName, + '--peer-tcp', $peerAddress, + '--profile', $profile, + '--output', $reportPath + ) + if ($run.exit_code -ne 0) { + throw "Serial conformance profile '$profile' failed: $($run.output)" + } + $results.serial_conformance[$profile] = [ordered]@{ + command = $run + report = Get-Content -LiteralPath $reportPath -Raw | ConvertFrom-Json + } + } + Stop-Process -Id $peerProcess.Id -Force + $peerProcess.WaitForExit() + $peerProcess = $null + Start-Sleep -Milliseconds 500 + $results.cases += [ordered]@{ + name = 'native_serial_api_conformance' + passed = $true + profiles = @('n1mm-radio', 'n1mm-winkeyer') + } + $process = Start-Process -FilePath $CatHubExe ` -ArgumentList @('--config', $configPath) ` -RedirectStandardOutput $stdoutPath ` @@ -608,6 +665,10 @@ finally { Stop-Process -Id $process.Id -Force $process.WaitForExit() } + if ($peerProcess -and -not $peerProcess.HasExited) { + Stop-Process -Id $peerProcess.Id -Force + $peerProcess.WaitForExit() + } if ($results.passed -and -not $KeepInstalled) { try { @@ -689,6 +750,12 @@ finally { $results.cathub_stderr = if (Test-Path -LiteralPath $stderrPath) { Get-Content -LiteralPath $stderrPath -Raw } else { '' } + $results.test_peer_stdout = if (Test-Path -LiteralPath $peerStdoutPath) { + Get-Content -LiteralPath $peerStdoutPath -Raw + } else { '' } + $results.test_peer_stderr = if (Test-Path -LiteralPath $peerStderrPath) { + Get-Content -LiteralPath $peerStderrPath -Raw + } else { '' } $results.events = [ordered]@{ system = Get-EventEvidence -LogName 'System' -StartTime $testStart code_integrity = Get-EventEvidence ` From 3454a56733da14ab37a3886cabca8d3b3a28e814 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 22:03:12 -0700 Subject: [PATCH 28/39] Verify managed serial buffer saturation --- .../src/conformance/mod.rs | 3 + .../src/conformance/profiles.rs | 8 ++ .../src/conformance/windows.rs | 86 ++++++++++++++++++- docs/integration/windows-virtual-serial.md | 5 +- 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/crates/cathub-virtual-serial/src/conformance/mod.rs b/crates/cathub-virtual-serial/src/conformance/mod.rs index baea300..8cc9746 100644 --- a/crates/cathub-virtual-serial/src/conformance/mod.rs +++ b/crates/cathub-virtual-serial/src/conformance/mod.rs @@ -35,6 +35,8 @@ pub enum CaseId { ModemControl, /// Queue counters and error status through `ClearCommError`. QueueStatus, + /// Bounded-buffer overflow fails atomically and leaves the handle usable. + BufferSaturation, } impl CaseId { @@ -51,6 +53,7 @@ impl CaseId { Self::SerialConfiguration => "serial_configuration", Self::ModemControl => "modem_control", Self::QueueStatus => "queue_status", + Self::BufferSaturation => "buffer_saturation", } } } diff --git a/crates/cathub-virtual-serial/src/conformance/profiles.rs b/crates/cathub-virtual-serial/src/conformance/profiles.rs index 122b0e3..90dba94 100644 --- a/crates/cathub-virtual-serial/src/conformance/profiles.rs +++ b/crates/cathub-virtual-serial/src/conformance/profiles.rs @@ -16,6 +16,10 @@ const BASE_CAT: &[ProfileCase] = &[ ), optional(CaseId::ModemControl, "client trace required"), required(CaseId::QueueStatus, "CatHub issue #6 acceptance scope"), + required( + CaseId::BufferSaturation, + "CatHub issue #6 bounded-buffer safety scope", + ), ]; const WINKYER: &[ProfileCase] = &[ @@ -34,6 +38,10 @@ const WINKYER: &[ProfileCase] = &[ ), optional(CaseId::ModemControl, "client trace required"), required(CaseId::QueueStatus, "CatHub issue #6 acceptance scope"), + required( + CaseId::BufferSaturation, + "CatHub issue #6 bounded-buffer safety scope", + ), ]; const PROFILES: &[ApplicationProfile] = &[ diff --git a/crates/cathub-virtual-serial/src/conformance/windows.rs b/crates/cathub-virtual-serial/src/conformance/windows.rs index e2a9711..17274a0 100644 --- a/crates/cathub-virtual-serial/src/conformance/windows.rs +++ b/crates/cathub-virtual-serial/src/conformance/windows.rs @@ -1,6 +1,6 @@ #![allow(unsafe_code, clippy::borrow_as_ptr)] -use std::io::{Read, Write}; +use std::io::{ErrorKind, Read, Write}; use std::mem::{size_of, zeroed}; use std::net::{SocketAddr, TcpStream}; use std::ptr::{null, null_mut}; @@ -30,6 +30,7 @@ use crate::TEST_PEER_READY; const IO_WAIT_MS: u32 = 2_000; const TEST_DATA: &[u8] = b"CatHub serial conformance"; +const DRIVER_BUFFER_CAPACITY: usize = 64 * 1024; #[derive(Clone, Copy)] enum PeerTarget<'a> { @@ -115,6 +116,7 @@ fn run_case( CaseId::SerialConfiguration => serial_configuration(application_port, profile), CaseId::ModemControl => modem_control(application_port), CaseId::QueueStatus => queue_status(application_port, peer_target), + CaseId::BufferSaturation => buffer_saturation(application_port, peer_target), }; match result { @@ -399,6 +401,51 @@ fn queue_status( Ok(format!("ClearCommError reported {depth} queued bytes")) } +fn buffer_saturation( + application_port: &str, + peer_target: PeerTarget<'_>, +) -> Result { + let application = Port::open(application_port, false)?; + let mut peer = Peer::open(peer_target)?; + set_timeout(application.handle(), 500)?; + + let oversized = vec![0xA5_u8; DRIVER_BUFFER_CAPACITY + 1]; + let length = u32::try_from(oversized.len()).map_err(|_| { + ConformanceError::InvalidResult("overflow payload is too large".to_string()) + })?; + let mut written = 0_u32; + let succeeded = unsafe { + WriteFile( + application.handle(), + oversized.as_ptr(), + length, + &mut written, + null_mut(), + ) + }; + if succeeded != 0 { + return Err(ConformanceError::InvalidResult( + "a write larger than the driver buffer unexpectedly succeeded".to_string(), + )); + } + let overflow_error = unsafe { GetLastError() }; + if written != 0 { + return Err(ConformanceError::InvalidResult(format!( + "overflowing write reported {written} partially accepted bytes" + ))); + } + peer.require_no_data(Duration::from_millis(150))?; + + let marker = b"after-overflow"; + write_sync(application.handle(), marker)?; + let received = peer.read_exact(marker.len())?; + require_equal("post-overflow application write", &received, marker)?; + Ok(format!( + "{}-byte write failed atomically with Win32 error {overflow_error}; handle recovered", + oversized.len() + )) +} + fn set_timeout(handle: HANDLE, milliseconds: u32) -> Result<(), ConformanceError> { let mut original: COMMTIMEOUTS = unsafe { zeroed() }; if unsafe { GetCommTimeouts(handle, &mut original) } == 0 { @@ -660,6 +707,43 @@ impl Peer { } } } + + fn require_no_data(&mut self, timeout: Duration) -> Result<(), ConformanceError> { + match self { + Self::Serial(port) => { + let milliseconds = u32::try_from(timeout.as_millis()).map_err(|_| { + ConformanceError::InvalidResult("peer timeout exceeds u32".to_string()) + })?; + set_timeout(port.handle(), milliseconds)?; + let received = read_sync(port.handle(), 1)?; + if received.is_empty() { + Ok(()) + } else { + Err(ConformanceError::InvalidResult( + "overflowing write leaked data to the serial peer".to_string(), + )) + } + } + Self::Tcp(stream) => { + stream.set_read_timeout(Some(timeout))?; + let mut byte = [0_u8; 1]; + match Read::read(stream, &mut byte) { + Err(error) + if matches!(error.kind(), ErrorKind::TimedOut | ErrorKind::WouldBlock) => + { + Ok(()) + } + Ok(0) => Err(ConformanceError::InvalidResult( + "managed test peer disconnected after buffer overflow".to_string(), + )), + Ok(_) => Err(ConformanceError::InvalidResult( + "overflowing write leaked data to the managed peer".to_string(), + )), + Err(error) => Err(error.into()), + } + } + } + } } struct Event(HANDLE); diff --git a/docs/integration/windows-virtual-serial.md b/docs/integration/windows-virtual-serial.md index a21c0a7..5ce3f68 100644 --- a/docs/integration/windows-virtual-serial.md +++ b/docs/integration/windows-virtual-serial.md @@ -109,8 +109,9 @@ ephemeral test certificate, PnP driver metadata, serial I/O and recovery cases, Integrity, and UMDF event logs. Before starting the real CatHub daemon it also runs the native Win32 conformance suite through a loopback-only bridge to the private CHVS channel. That covers synchronous and overlapped I/O, pending-read cancellation, read timeouts, purge, `WaitCommEvent`, -queue status, modem controls, and both CAT 8-N-1 and WinKeyer 8-N-2 line formats without recreating -a second COM port. After a successful run it removes the endpoint, staged OEM driver package, and +queue status, modem controls, atomic rejection beyond the 64 KiB buffer limit, recovery on the same +handle, and both CAT 8-N-1 and WinKeyer 8-N-2 line formats without recreating a second COM port. +After a successful run it removes the endpoint, staged OEM driver package, and test certificate and verifies the resulting CatHub inventory. Pass `-KeepInstalled` only when retaining that isolated VM state is necessary for debugging; failed runs retain state so the original failure can be inspected before reverting the VM checkpoint. From 10e094225ffc348280e0159bb67954d1c9f0e913 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 22:06:31 -0700 Subject: [PATCH 29/39] Harden CHVS decoders against malformed input --- crates/cathub-virtual-serial/src/protocol.rs | 48 +++++++++++++++++++ .../src/private_protocol.rs | 26 ++++++++++ 2 files changed, 74 insertions(+) diff --git a/crates/cathub-virtual-serial/src/protocol.rs b/crates/cathub-virtual-serial/src/protocol.rs index a9f12c9..323b60e 100644 --- a/crates/cathub-virtual-serial/src/protocol.rs +++ b/crates/cathub-virtual-serial/src/protocol.rs @@ -786,4 +786,52 @@ mod tests { None ); } + + #[test] + fn deterministic_arbitrary_byte_corpus_never_panics_the_decoders() { + let mut state = 0xC4A7_4B55_D15C_A11Eu64; + for length in 0..=2_048 { + let mut bytes = vec![0_u8; length]; + for byte in &mut bytes { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + *byte = state.to_le_bytes()[0]; + } + + let _ = Frame::decode(&bytes); + let mut decoder = FrameDecoder::default(); + for chunk in bytes.chunks(1 + (length % 31)) { + decoder.push(chunk); + while let Ok(Some(_)) = decoder.next_frame() {} + } + assert!(decoder.buffered_len() <= bytes.len()); + } + } + + #[test] + fn every_single_byte_frame_mutation_is_rejected_or_round_trips() { + let mut frame = Frame::new(MessageKind::Data, 1, 7); + frame + .fields + .insert_u64(data_field::SEQUENCE, 1) + .expect("sequence"); + frame + .fields + .insert(data_field::BYTES, b"ID;") + .expect("bytes"); + let encoded = frame.encode().expect("encode"); + + for index in 0..encoded.len() { + for mask in [0x01_u8, 0x80, 0xFF] { + let mut mutated = encoded.clone(); + mutated[index] ^= mask; + if let Ok(decoded) = Frame::decode(&mutated) { + let reparsed = + Frame::decode(&decoded.encode().expect("re-encode")).expect("re-decode"); + assert_eq!(reparsed, decoded); + } + } + } + } } diff --git a/drivers/cathub-virtual-serial-umdf/src/private_protocol.rs b/drivers/cathub-virtual-serial-umdf/src/private_protocol.rs index 16bd7ba..4477fe7 100644 --- a/drivers/cathub-virtual-serial-umdf/src/private_protocol.rs +++ b/drivers/cathub-virtual-serial-umdf/src/private_protocol.rs @@ -844,4 +844,30 @@ mod tests { .expect("window update"); assert!(daemon_events(&mut daemon, &[update]).is_empty()); } + + #[test] + fn malformed_daemon_corpus_never_panics_the_driver_state_machine() { + let valid_hello = hello(1).encode().expect("hello encoding"); + for index in 0..valid_hello.len() { + for mask in [0x01_u8, 0x80, 0xFF] { + let mut mutated = valid_hello.clone(); + mutated[index] ^= mask; + let mut protocol = DriverProtocol::new(); + let _ = protocol.ingest_daemon(&mutated); + } + } + + let mut state = 0x55D1_5EA5_EC0D_ED01u64; + for length in 0..=2_048 { + let mut bytes = vec![0_u8; length]; + for byte in &mut bytes { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + *byte = state.to_le_bytes()[0]; + } + let mut protocol = DriverProtocol::new(); + let _ = protocol.ingest_daemon(&bytes); + } + } } From 47d759bbe60a119e6cf7a8797aef077c480898f4 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Fri, 21 Aug 2026 22:08:05 -0700 Subject: [PATCH 30/39] Verify managed COM exclusive-open behavior --- .../src/conformance/mod.rs | 3 +++ .../src/conformance/profiles.rs | 2 ++ .../src/conformance/windows.rs | 20 +++++++++++++++++++ docs/integration/windows-virtual-serial.md | 3 ++- 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/cathub-virtual-serial/src/conformance/mod.rs b/crates/cathub-virtual-serial/src/conformance/mod.rs index 8cc9746..f8784f2 100644 --- a/crates/cathub-virtual-serial/src/conformance/mod.rs +++ b/crates/cathub-virtual-serial/src/conformance/mod.rs @@ -37,6 +37,8 @@ pub enum CaseId { QueueStatus, /// Bounded-buffer overflow fails atomically and leaves the handle usable. BufferSaturation, + /// The COM device allows one application handle and reopens after close. + ExclusiveOpen, } impl CaseId { @@ -54,6 +56,7 @@ impl CaseId { Self::ModemControl => "modem_control", Self::QueueStatus => "queue_status", Self::BufferSaturation => "buffer_saturation", + Self::ExclusiveOpen => "exclusive_open", } } } diff --git a/crates/cathub-virtual-serial/src/conformance/profiles.rs b/crates/cathub-virtual-serial/src/conformance/profiles.rs index 90dba94..65ec743 100644 --- a/crates/cathub-virtual-serial/src/conformance/profiles.rs +++ b/crates/cathub-virtual-serial/src/conformance/profiles.rs @@ -20,6 +20,7 @@ const BASE_CAT: &[ProfileCase] = &[ CaseId::BufferSaturation, "CatHub issue #6 bounded-buffer safety scope", ), + required(CaseId::ExclusiveOpen, "CatHub issue #6 acceptance scope"), ]; const WINKYER: &[ProfileCase] = &[ @@ -42,6 +43,7 @@ const WINKYER: &[ProfileCase] = &[ CaseId::BufferSaturation, "CatHub issue #6 bounded-buffer safety scope", ), + required(CaseId::ExclusiveOpen, "CatHub issue #6 acceptance scope"), ]; const PROFILES: &[ApplicationProfile] = &[ diff --git a/crates/cathub-virtual-serial/src/conformance/windows.rs b/crates/cathub-virtual-serial/src/conformance/windows.rs index 17274a0..223048e 100644 --- a/crates/cathub-virtual-serial/src/conformance/windows.rs +++ b/crates/cathub-virtual-serial/src/conformance/windows.rs @@ -117,6 +117,7 @@ fn run_case( CaseId::ModemControl => modem_control(application_port), CaseId::QueueStatus => queue_status(application_port, peer_target), CaseId::BufferSaturation => buffer_saturation(application_port, peer_target), + CaseId::ExclusiveOpen => exclusive_open(application_port), }; match result { @@ -446,6 +447,25 @@ fn buffer_saturation( )) } +fn exclusive_open(application_port: &str) -> Result { + let first = Port::open(application_port, false)?; + let second_error = match Port::open(application_port, false) { + Ok(_) => { + return Err(ConformanceError::InvalidResult( + "a second application handle unexpectedly opened the COM port".to_string(), + )); + } + Err(ConformanceError::Win32 { code, .. }) => code, + Err(error) => return Err(error), + }; + drop(first); + let reopened = Port::open(application_port, false)?; + drop(reopened); + Ok(format!( + "second handle was rejected with Win32 error {second_error}; reopen after close succeeded" + )) +} + fn set_timeout(handle: HANDLE, milliseconds: u32) -> Result<(), ConformanceError> { let mut original: COMMTIMEOUTS = unsafe { zeroed() }; if unsafe { GetCommTimeouts(handle, &mut original) } == 0 { diff --git a/docs/integration/windows-virtual-serial.md b/docs/integration/windows-virtual-serial.md index 5ce3f68..5b92217 100644 --- a/docs/integration/windows-virtual-serial.md +++ b/docs/integration/windows-virtual-serial.md @@ -110,7 +110,8 @@ Integrity, and UMDF event logs. Before starting the real CatHub daemon it also r Win32 conformance suite through a loopback-only bridge to the private CHVS channel. That covers synchronous and overlapped I/O, pending-read cancellation, read timeouts, purge, `WaitCommEvent`, queue status, modem controls, atomic rejection beyond the 64 KiB buffer limit, recovery on the same -handle, and both CAT 8-N-1 and WinKeyer 8-N-2 line formats without recreating a second COM port. +handle, exclusive-open/reopen behavior, and both CAT 8-N-1 and WinKeyer 8-N-2 line formats without +recreating a second COM port. After a successful run it removes the endpoint, staged OEM driver package, and test certificate and verifies the resulting CatHub inventory. Pass `-KeepInstalled` only when retaining that isolated VM state is necessary for debugging; failed runs retain state so the From d77aadba154cbbba69dc33472a48c33738a652e3 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Sat, 22 Aug 2026 14:01:44 -0700 Subject: [PATCH 31/39] Harden first-time UMDF provisioning diagnostics --- .../cathub/src/virtual_serial_provisioning.rs | 89 +++++++++++++++++-- .../cathub-virtual-serial-umdf/src/interop.rs | 66 ++++++++++++-- scripts/Test-UmdfEndToEnd.ps1 | 69 ++++++++------ 3 files changed, 182 insertions(+), 42 deletions(-) diff --git a/crates/cathub/src/virtual_serial_provisioning.rs b/crates/cathub/src/virtual_serial_provisioning.rs index 3a4c0bc..2dcd563 100644 --- a/crates/cathub/src/virtual_serial_provisioning.rs +++ b/crates/cathub/src/virtual_serial_provisioning.rs @@ -88,6 +88,9 @@ pub(crate) struct InstalledEndpoint { display_name: String, com_port: Option, authorized_for_current_user: bool, + started: bool, + problem_code: u32, + devnode_status: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -190,6 +193,12 @@ impl TextReport for StatusReport { if !endpoint.authorized_for_current_user { lines.push(" Private daemon access requires owner reconciliation.".to_string()); } + if !endpoint.started { + lines.push(format!( + " PnP device is not started (problem code {}, status 0x{:08x}).", + endpoint.problem_code, endpoint.devnode_status + )); + } } if self.owned_endpoints.is_empty() { lines.push(" No CatHub-owned devices are installed.".to_string()); @@ -305,6 +314,21 @@ pub(crate) fn apply(config: &Config, inf_path: &Path) -> Result>(); + if let Some(endpoint) = after + .owned + .iter() + .find(|endpoint| desired_ids.contains(endpoint.stable_id.as_str()) && !endpoint.started) + { + return Err(format!( + "CatHub endpoint `{}` was provisioned but its PnP device did not start (problem code {}, status 0x{:08x})", + endpoint.stable_id, endpoint.problem_code, endpoint.devnode_status + )); + } let verification = plan_from_snapshot(plan.desired.clone(), &after)?; if !verification.is_applicable() || verification.requires_changes() { return Err("provisioning completed but the resulting PnP/COM state does not match the requested plan".to_string()); @@ -571,14 +595,15 @@ mod platform { use std::ptr::{null, null_mut}; use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{ - DiInstallDriverW, SetupDiCallClassInstaller, SetupDiCreateDeviceInfoList, - SetupDiCreateDeviceInfoW, SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInfo, - SetupDiGetClassDevsW, SetupDiGetDeviceInstanceIdW, SetupDiGetDeviceRegistryPropertyW, - SetupDiOpenDevRegKey, SetupDiOpenDeviceInfoW, SetupDiRemoveDevice, SetupDiRestartDevices, - SetupDiSetDeviceRegistryPropertyW, UpdateDriverForPlugAndPlayDevicesW, DICD_GENERATE_ID, - DICS_FLAG_GLOBAL, DIF_REGISTERDEVICE, DIGCF_ALLCLASSES, DIIRFLAG_FORCE_INF, DIREG_DEV, - GUID_DEVCLASS_PORTS, HDEVINFO, INSTALLFLAG_FORCE, SPDRP_DEVICEDESC, SPDRP_FRIENDLYNAME, - SPDRP_HARDWAREID, SP_DEVINFO_DATA, + CM_Get_DevNode_Status, DiInstallDriverW, SetupDiCallClassInstaller, + SetupDiCreateDevRegKeyW, SetupDiCreateDeviceInfoList, SetupDiCreateDeviceInfoW, + SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInfo, SetupDiGetClassDevsW, + SetupDiGetDeviceInstanceIdW, SetupDiGetDeviceRegistryPropertyW, SetupDiOpenDevRegKey, + SetupDiOpenDeviceInfoW, SetupDiRemoveDevice, SetupDiRestartDevices, + SetupDiSetDeviceRegistryPropertyW, UpdateDriverForPlugAndPlayDevicesW, CR_SUCCESS, + DICD_GENERATE_ID, DICS_FLAG_GLOBAL, DIF_REGISTERDEVICE, DIGCF_ALLCLASSES, + DIIRFLAG_FORCE_INF, DIREG_DEV, DN_STARTED, GUID_DEVCLASS_PORTS, HDEVINFO, + INSTALLFLAG_FORCE, SPDRP_DEVICEDESC, SPDRP_FRIENDLYNAME, SPDRP_HARDWAREID, SP_DEVINFO_DATA, }; use windows_sys::Win32::Foundation::{ CloseHandle, GetLastError, ERROR_INSUFFICIENT_BUFFER, ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, @@ -738,6 +763,7 @@ mod platform { }); } if let Some(definition) = definition { + let (devnode_status, problem_code) = device_status(&data)?; let owner_sid = device_binary_value(set.0, &data, OWNER_SID_VALUE); let display_name = device_property_strings(set.0, &data, SPDRP_FRIENDLYNAME) .into_iter() @@ -757,6 +783,9 @@ mod platform { com_port: port, authorized_for_current_user: owner_sid.as_deref() == Some(current_sid.as_slice()), + started: devnode_status & DN_STARTED != 0 && problem_code == 0, + problem_code, + devnode_status, }); } } @@ -774,6 +803,21 @@ mod platform { Ok(SystemSnapshot { owned, claims }) } + fn device_status(data: &SP_DEVINFO_DATA) -> Result<(u32, u32), String> { + let mut status = 0; + let mut problem = 0; + // SAFETY: `DevInst` identifies the enumerated live devnode and both outputs are valid. + let result = + unsafe { CM_Get_DevNode_Status(&raw mut status, &raw mut problem, data.DevInst, 0) }; + if result != CR_SUCCESS { + return Err(format!( + "reading PnP status for devnode {} failed with CONFIGRET {result}", + data.DevInst + )); + } + Ok((status, problem)) + } + pub(super) fn apply(plan: &ProvisionPlan, inf_path: &Path) -> Result { require_administrator()?; let inf = wide(&inf_path.to_string_lossy()); @@ -933,6 +977,7 @@ mod platform { ))); } let result = (|| { + create_device_registry_key(set.0, &data, com_port)?; set_port_name(set.0, &data, com_port)?; set_owner_sid(set.0, &data, owner_sid)?; let hardware_id = wide(hardware_id); @@ -961,6 +1006,28 @@ mod platform { result } + fn create_device_registry_key( + set: HDEVINFO, + data: &SP_DEVINFO_DATA, + com_port: &str, + ) -> Result<(), String> { + // Newly registered root devices do not necessarily have a hardware key yet. Create it + // before storing PortName and CatHubOwnerSid; existing-device reconciliation only opens + // the key and therefore cannot accidentally create registry state for foreign devices. + // SAFETY: The device data belongs to the live set. Null INF inputs request an empty key. + let key = unsafe { + SetupDiCreateDevRegKeyW(set, data, DICS_FLAG_GLOBAL, 0, DIREG_DEV, null(), null()) + }; + if key as isize == INVALID_HANDLE_VALUE as isize { + return Err(last_error(&format!( + "creating the device registry key for {com_port}" + ))); + } + // SAFETY: This function owns the registry handle returned above. + unsafe { RegCloseKey(key) }; + Ok(()) + } + fn set_existing_port( instance_id: &str, com_port: &str, @@ -1533,6 +1600,9 @@ dialect = "ts590" display_name: "CatHub N1MM CAT Port".to_string(), com_port: Some("COM21".to_string()), authorized_for_current_user: true, + started: true, + problem_code: 0, + devnode_status: 0x08, }], claims: vec![PortClaim { com_port: "COM21".to_string(), @@ -1567,6 +1637,9 @@ dialect = "ts590" display_name: "CatHub N1MM CAT Port".to_string(), com_port: Some("COM21".to_string()), authorized_for_current_user: false, + started: true, + problem_code: 0, + devnode_status: 0x08, }], claims: vec![PortClaim { com_port: "COM21".to_string(), diff --git a/drivers/cathub-virtual-serial-umdf/src/interop.rs b/drivers/cathub-virtual-serial-umdf/src/interop.rs index 35d78d7..b210439 100644 --- a/drivers/cathub-virtual-serial-umdf/src/interop.rs +++ b/drivers/cathub-virtual-serial-umdf/src/interop.rs @@ -17,12 +17,12 @@ use wdk::println; use wdk_sys::{ _SECURITY_IMPERSONATION_LEVEL, _WDF_EXECUTION_LEVEL, _WDF_FILEOBJECT_CLASS, _WDF_IO_QUEUE_DISPATCH_TYPE, _WDF_SYNCHRONIZATION_SCOPE, _WDF_TRI_STATE, BOOLEAN, GUID, - KEY_QUERY_VALUE, NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PLUGPLAY_REGKEY_DEVICE, PVOID, - ULONG, ULONG_PTR, UNICODE_STRING, WDF_DRIVER_CONFIG, WDF_FILEOBJECT_CONFIG, - WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES, - WDF_OBJECT_CONTEXT_TYPE_INFO, WDF_TIMER_CONFIG, WDFDEVICE, WDFDEVICE_INIT, WDFDRIVER, - WDFFILEOBJECT, WDFKEY, WDFOBJECT, WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER, - call_unsafe_wdf_function_binding, + KEY_QUERY_VALUE, KEY_SET_VALUE, NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, + PLUGPLAY_REGKEY_DEVICE, PVOID, ULONG, ULONG_PTR, UNICODE_STRING, WDF_DRIVER_CONFIG, + WDF_FILEOBJECT_CONFIG, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, + WDF_OBJECT_ATTRIBUTES, WDF_OBJECT_CONTEXT_TYPE_INFO, WDF_TIMER_CONFIG, WDFDEVICE, + WDFDEVICE_INIT, WDFDRIVER, WDFFILEOBJECT, WDFKEY, WDFOBJECT, WDFQUEUE, WDFQUEUE__, WDFREQUEST, + WDFTIMER, call_unsafe_wdf_function_binding, }; use windows_sys::Win32::Security::{CheckTokenMembership, GetLengthSid, IsValidSid}; @@ -61,6 +61,12 @@ const DISPLAY_NAME_VALUE: [u16; 18] = [ const OWNER_SID_VALUE: [u16; 15] = [ 67, 97, 116, 72, 117, 98, 79, 119, 110, 101, 114, 83, 105, 100, 0, ]; +const STARTUP_STAGE_VALUE: [u16; 19] = [ + 67, 97, 116, 72, 117, 98, 83, 116, 97, 114, 116, 117, 112, 83, 116, 97, 103, 101, 0, +]; +const STARTUP_STATUS_VALUE: [u16; 20] = [ + 67, 97, 116, 72, 117, 98, 83, 116, 97, 114, 116, 117, 112, 83, 116, 97, 116, 117, 115, 0, +]; const DOS_DEVICE_PREFIX: &[u16] = &[ 92, 68, 111, 115, 68, 101, 118, 105, 99, 101, 115, 92, 71, 108, 111, 98, 97, 108, 92, ]; @@ -333,8 +339,12 @@ unsafe fn create_device(mut device_init: *mut WDFDEVICE_INIT) -> NTSTATUS { if !nt_success(status) { return status; } + // SAFETY: Device creation succeeded, so its hardware key can record startup diagnostics. + unsafe { record_startup_diagnostic(device, 1, STATUS_SUCCESS) }; // SAFETY: Device creation allocated and zeroed the registered context space. let Some(context) = (unsafe { device_context(device) }) else { + // SAFETY: The device remains live until this callback returns the failure. + unsafe { record_startup_diagnostic(device, 2, STATUS_UNSUCCESSFUL) }; return STATUS_UNSUCCESSFUL; }; // SAFETY: The live WDF device owns a queryable PnP instance registry key. @@ -349,16 +359,58 @@ unsafe fn create_device(mut device_init: *mut WDFDEVICE_INIT) -> NTSTATUS { let state = unsafe { &*state }; // SAFETY: The WDF device was successfully created above. let status = unsafe { configure_queues(device, state) }; + // SAFETY: The WDF device is still live even when queue configuration fails. + unsafe { record_startup_diagnostic(device, 3, status) }; if !nt_success(status) { return status; } // SAFETY: The device is live and owns the periodic timer for its full lifetime. let status = unsafe { configure_read_timeout_timer(device) }; + // SAFETY: The WDF device is still live even when timer configuration fails. + unsafe { record_startup_diagnostic(device, 4, status) }; if !nt_success(status) { return status; } // SAFETY: The device exists and registration consumes both counted strings synchronously. - unsafe { register_interfaces(device) } + let status = unsafe { register_interfaces(device) }; + // SAFETY: The WDF device is live through the end of this callback. + unsafe { record_startup_diagnostic(device, 5, status) }; + status +} + +unsafe fn record_startup_diagnostic(device: WDFDEVICE, stage: u32, status: NTSTATUS) { + let mut key: WDFKEY = ptr::null_mut(); + // SAFETY: The device is live and output storage is valid. Diagnostics are best effort. + let open_status = unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceOpenRegistryKey, + device, + PLUGPLAY_REGKEY_DEVICE, + KEY_SET_VALUE, + WDF_NO_OBJECT_ATTRIBUTES, + &raw mut key, + ) + }; + if !nt_success(open_status) { + return; + } + let stage_name = unicode_string(&STARTUP_STAGE_VALUE); + let status_name = unicode_string(&STARTUP_STATUS_VALUE); + // SAFETY: The registry key and stage value name remain valid for this synchronous write. + let _ = unsafe { + call_unsafe_wdf_function_binding!(WdfRegistryAssignULong, key, &raw const stage_name, stage,) + }; + // SAFETY: The registry key and status value name remain valid for this synchronous write. + let _ = unsafe { + call_unsafe_wdf_function_binding!( + WdfRegistryAssignULong, + key, + &raw const status_name, + status.cast_unsigned(), + ) + }; + // SAFETY: This function owns the WDF registry handle returned above. + unsafe { call_unsafe_wdf_function_binding!(WdfRegistryClose, key) }; } unsafe fn configure_read_timeout_timer(device: WDFDEVICE) -> NTSTATUS { diff --git a/scripts/Test-UmdfEndToEnd.ps1 b/scripts/Test-UmdfEndToEnd.ps1 index 0a229df..5876ffd 100644 --- a/scripts/Test-UmdfEndToEnd.ps1 +++ b/scripts/Test-UmdfEndToEnd.ps1 @@ -8,6 +8,10 @@ param( [string]$ConformanceExe = (Join-Path $PSScriptRoot 'serial-conformance.exe'), [string]$ResultsPath = (Join-Path $PSScriptRoot 'cathub-umdf-e2e.json'), + [switch]$AllowLocalMachine, + + [switch]$AllowSecureBootDisabled, + [switch]$KeepInstalled ) @@ -186,29 +190,23 @@ function Get-EventEvidence { } function Find-CatHubDevice { + param( + [Parameter(Mandatory)][string]$Executable, + [Parameter(Mandatory)][string]$StableId + ) + $deadline = [DateTime]::UtcNow.AddSeconds(20) do { - $device = Get-PnpDevice -Class Ports -PresentOnly -ErrorAction SilentlyContinue | - Where-Object InstanceId -Like 'ROOT\CATHUB_VIRTUAL_SERIAL*' | - Select-Object -First 1 - if ($device) { - $match = [regex]::Match($device.FriendlyName, '\((COM\d+)\)') - if ($match.Success) { + $statusJson = (& $Executable virtual-serial status --format json 2>$null | Out-String) + if ($LASTEXITCODE -eq 0) { + $status = $statusJson | ConvertFrom-Json + $endpoint = $status.owned_endpoints | + Where-Object stable_id -EQ $StableId | + Select-Object -First 1 + if ($endpoint -and $endpoint.com_port -match '^COM\d+$') { return [pscustomobject]@{ - InstanceId = $device.InstanceId - Port = $match.Groups[1].Value - } - } - - $enumPath = "HKLM:\SYSTEM\CurrentControlSet\Enum\$($device.InstanceId)" - foreach ($path in @($enumPath, (Join-Path $enumPath 'Device Parameters'))) { - $portName = (Get-ItemProperty -LiteralPath $path -Name PortName ` - -ErrorAction SilentlyContinue).PortName - if ($portName -match '^COM\d+$') { - return [pscustomobject]@{ - InstanceId = $device.InstanceId - Port = $portName - } + InstanceId = $endpoint.instance_id + Port = $endpoint.com_port } } } @@ -229,12 +227,19 @@ function Invoke-CatQuery { } if (-not $IUnderstandThisInstallsATestDriver) { - throw 'Pass -IUnderstandThisInstallsATestDriver on an isolated test VM.' + throw 'Pass -IUnderstandThisInstallsATestDriver to acknowledge the test driver installation.' } $computer = Get-CimInstance Win32_ComputerSystem -if ($computer.Manufacturer -ne 'Microsoft Corporation' -or $computer.Model -ne 'Virtual Machine') { - throw 'This test installs a private test certificate and driver and is restricted to a Hyper-V VM.' +$isHyperVGuest = ( + $computer.Manufacturer -eq 'Microsoft Corporation' -and + $computer.Model -eq 'Virtual Machine' +) +if (-not $isHyperVGuest -and -not $AllowLocalMachine) { + throw 'This test installs a private test certificate and driver. Use a Hyper-V VM or explicitly pass -AllowLocalMachine.' +} +if ($AllowSecureBootDisabled -and -not $AllowLocalMachine) { + throw '-AllowSecureBootDisabled is restricted to an explicitly authorized local-machine development run.' } $identity = [Security.Principal.WindowsIdentity]::GetCurrent() @@ -299,6 +304,13 @@ $failure = $null $results = [ordered]@{ timestamp_utc = $testStart.ToString('o') machine = $env:COMPUTERNAME + target = [ordered]@{ + manufacturer = $computer.Manufacturer + model = $computer.Model + hyper_v_guest = $isHyperVGuest + local_machine_opt_in = [bool]$AllowLocalMachine + secure_boot_exception_used = [bool]$AllowSecureBootDisabled + } os = [ordered]@{ caption = $operatingSystem.Caption version = $operatingSystem.Version @@ -327,8 +339,11 @@ try { if (-not $results.package_integrity.passed) { throw 'One or more staged driver files do not match package-manifest.json.' } - if ($results.security.secure_boot_enabled -ne $true) { - throw 'Secure Boot is not enabled in the isolated Windows test target.' + if ( + $results.security.secure_boot_enabled -ne $true -and + -not $AllowSecureBootDisabled + ) { + throw 'Secure Boot is not enabled on the Windows test target.' } if ($results.security.testsigning_enabled) { throw 'Windows test-signing mode is enabled; the acceptance target must use normal policy.' @@ -374,7 +389,7 @@ try { throw "CatHub virtual-serial apply failed: $($results.apply.output)" } - $device = Find-CatHubDevice + $device = Find-CatHubDevice -Executable $CatHubExe -StableId 'cathub-default' $portName = $device.Port $results.port = $portName $results.device_instance_id = $device.InstanceId @@ -603,7 +618,7 @@ try { $serial.Dispose() $serial = $null - $restartedDevice = Find-CatHubDevice + $restartedDevice = Find-CatHubDevice -Executable $CatHubExe -StableId 'cathub-default' if ($restartedDevice.InstanceId -ne $device.InstanceId) { throw "Device restart changed instance ID to '$($restartedDevice.InstanceId)'." } From 372334ba03e591f63b4f48179f1b539c41c1254f Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Sat, 22 Aug 2026 14:12:05 -0700 Subject: [PATCH 32/39] Sign UMDF image before catalog generation --- .../design/virtual-serial-signing-decision.md | 5 ++-- docs/integration/windows-virtual-serial.md | 30 +++++++++++++------ drivers/cathub-virtual-serial-umdf/README.md | 15 +++++++--- scripts/Test-UmdfEndToEnd.ps1 | 11 ++++--- scripts/Test-UmdfPoc.ps1 | 9 ++++++ 5 files changed, 49 insertions(+), 21 deletions(-) diff --git a/docs/design/virtual-serial-signing-decision.md b/docs/design/virtual-serial-signing-decision.md index 8029115..44ae4e3 100644 --- a/docs/design/virtual-serial-signing-decision.md +++ b/docs/design/virtual-serial-signing-decision.md @@ -17,8 +17,9 @@ Status: research complete; provider approval and clean-system evidence pending. dashboard account for attestation or WHCP submission: [Driver code signing requirements](https://learn.microsoft.com/en-us/windows-hardware/drivers/dashboard/code-signing-reqs). -The repository's ephemeral self-signed catalog is development-test material only. It deliberately -requires the isolated VM to trust its public test certificate and is not a production signing path. +The repository's ephemeral self-signed DLL and catalog are development-test material only. The +development target must trust the public test certificate and run with Windows Test Signing mode +enabled. This is not a production signing path. ## SignPath request diff --git a/docs/integration/windows-virtual-serial.md b/docs/integration/windows-virtual-serial.md index 5b92217..a017e84 100644 --- a/docs/integration/windows-virtual-serial.md +++ b/docs/integration/windows-virtual-serial.md @@ -88,25 +88,33 @@ catalog without installing that certificate on the build workstation: .\scripts\Test-UmdfPoc.ps1 -Action Package ``` -The resulting package is suitable only for the isolated VM procedure in +The resulting package is suitable only for the development-target procedure in `scripts/Test-UmdfEndToEnd.ps1`. Production distribution still requires the approved public catalog-signing path and clean-system Secure Boot/Memory Integrity acceptance evidence. See the [signing decision gate](../design/virtual-serial-signing-decision.md) for the current provider research and required acceptance record. -Run the development acceptance harness only from an elevated PowerShell session inside an isolated -Hyper-V VM: +The self-signed development image requires +[Windows Test Signing mode](https://learn.microsoft.com/en-us/windows-hardware/drivers/install/the-testsigning-boot-configuration-option). +On a dedicated development target, disable Secure Boot, enable Test Signing, and reboot. Leave +Memory Integrity enabled; the driver DLL remains signed and the harness rejects disabled integrity +checks. Then run the harness from an elevated PowerShell session: ```powershell +bcdedit.exe -set TESTSIGNING ON +# Reboot before continuing. cd C:\CatHubUmdfTest -.\Test-UmdfEndToEnd.ps1 -IUnderstandThisInstallsATestDriver +.\Test-UmdfEndToEnd.ps1 ` + -IUnderstandThisInstallsATestDriver ` + -AllowSecureBootDisabled ``` -The harness fails before installation unless Secure Boot and Memory Integrity are running and the -boot configuration has neither test-signing mode nor integrity checks disabled. Its JSON evidence -records the OS and boot policy, package manifest, catalog trust before and after importing the -ephemeral test certificate, PnP driver metadata, serial I/O and recovery cases, and System, Code -Integrity, and UMDF event logs. Before starting the real CatHub daemon it also runs the native +The harness fails before installation unless Test Signing and Memory Integrity are running, +integrity checks remain enabled, and disabled Secure Boot was explicitly acknowledged. Its JSON +evidence records the OS and boot policy, package manifest, catalog and embedded DLL trust before +and after importing the ephemeral test certificate, PnP driver metadata, serial I/O and recovery +cases, and System, Code Integrity, and UMDF event logs. Before starting the real CatHub daemon it +also runs the native Win32 conformance suite through a loopback-only bridge to the private CHVS channel. That covers synchronous and overlapped I/O, pending-read cancellation, read timeouts, purge, `WaitCommEvent`, queue status, modem controls, atomic rejection beyond the 64 KiB buffer limit, recovery on the same @@ -116,3 +124,7 @@ After a successful run it removes the endpoint, staged OEM driver package, and test certificate and verifies the resulting CatHub inventory. Pass `-KeepInstalled` only when retaining that isolated VM state is necessary for debugging; failed runs retain state so the original failure can be inspected before reverting the VM checkpoint. + +Normal-policy acceptance is a separate production-signing gate. After CatHub obtains a public or +Microsoft driver signature, repeat the clean-system procedure with Secure Boot enabled and Test +Signing disabled, without importing the development certificate. diff --git a/drivers/cathub-virtual-serial-umdf/README.md b/drivers/cathub-virtual-serial-umdf/README.md index 6e04ae8..766c5b4 100644 --- a/drivers/cathub-virtual-serial-umdf/README.md +++ b/drivers/cathub-virtual-serial-umdf/README.md @@ -29,6 +29,11 @@ driver build nor the packaging task follows a moving branch. - LLVM/libclang - `cargo-make` 0.37.16 or newer +Running the self-signed package also requires a development target with Windows Test Signing mode +enabled. Keep Memory Integrity enabled; the package embeds a signature in the driver DLL so HVCI +does not permit unsigned code. Production installation under normal boot policy requires a public +or Microsoft driver signature. + An SDK-only installation is not enough. The check script automatically finds an installed WDK, `WDKContentRoot`, or the newest package under `%LOCALAPPDATA%\CatHub\wdk\packages`. @@ -61,10 +66,12 @@ Produce a test-signed release package for an isolated driver-development target ``` The `Package` action generates a short-lived code-signing certificate without adding it to the -host certificate store, signs the package catalog with `signtool`, deletes the private-key file, -and leaves the public `cathub_umdf_test.cer` in the package for the isolated target. Building a -package does not authorize installing its certificate or driver. Keep the public test certificate -off normal operator machines. +host certificate store, embeds a signature in the UMDF driver DLL, regenerates and signs the +package catalog, deletes the private-key file, and leaves the public `cathub_umdf_test.cer` in the +package for the development target. The image signature is required for UMDF code-integrity +validation; it must be applied before catalog generation so the catalog contains the signed DLL's +hash. Building a package does not authorize installing its certificate or driver. Keep the public +test certificate off normal operator machines. ## Current safety boundary diff --git a/scripts/Test-UmdfEndToEnd.ps1 b/scripts/Test-UmdfEndToEnd.ps1 index 5876ffd..9cd29c7 100644 --- a/scripts/Test-UmdfEndToEnd.ps1 +++ b/scripts/Test-UmdfEndToEnd.ps1 @@ -238,10 +238,6 @@ $isHyperVGuest = ( if (-not $isHyperVGuest -and -not $AllowLocalMachine) { throw 'This test installs a private test certificate and driver. Use a Hyper-V VM or explicitly pass -AllowLocalMachine.' } -if ($AllowSecureBootDisabled -and -not $AllowLocalMachine) { - throw '-AllowSecureBootDisabled is restricted to an explicitly authorized local-machine development run.' -} - $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { @@ -345,8 +341,8 @@ try { ) { throw 'Secure Boot is not enabled on the Windows test target.' } - if ($results.security.testsigning_enabled) { - throw 'Windows test-signing mode is enabled; the acceptance target must use normal policy.' + if (-not $results.security.testsigning_enabled) { + throw 'Windows test-signing mode is disabled; a self-signed development driver cannot pass Windows code-integrity policy. Enable TESTSIGNING and reboot the development target first.' } if ($results.security.no_integrity_checks_enabled) { throw 'Windows integrity checks are disabled; the acceptance target must use normal policy.' @@ -378,6 +374,9 @@ try { if ($results.signatures_after_trust.catalog.status -ne 'Valid') { throw "The installed catalog signature is not valid: $($results.signatures_after_trust.catalog.status_message)" } + if ($results.signatures_after_trust.driver_dll.status -ne 'Valid') { + throw "The UMDF driver DLL's embedded signature is not valid: $($results.signatures_after_trust.driver_dll.status_message)" + } $results.apply = Invoke-Captured $CatHubExe @( '--config', $configPath, diff --git a/scripts/Test-UmdfPoc.ps1 b/scripts/Test-UmdfPoc.ps1 index befb9d7..2b15289 100644 --- a/scripts/Test-UmdfPoc.ps1 +++ b/scripts/Test-UmdfPoc.ps1 @@ -253,6 +253,7 @@ try { $packageRoot = Join-Path $driverRoot ` 'target\x86_64-pc-windows-msvc\release\cathub_virtual_serial_umdf_package' $catalogPath = Join-Path $packageRoot 'cathub_virtual_serial_umdf.cat' + $driverBinaryPath = Join-Path $packageRoot 'cathub_virtual_serial_umdf.dll' $certificatePath = Join-Path $packageRoot 'cathub_umdf_test.cer' $manifestPath = Join-Path $packageRoot 'package-manifest.json' if (Test-Path -LiteralPath $manifestPath) { @@ -266,6 +267,14 @@ try { -PfxPath $pfxPath ` -CerPath $certificatePath ` -Password $password + # UMDF loads the driver DLL as integrity-protected code. Sign the image before + # regenerating the catalog so both the embedded signature and catalog hash are valid. + Invoke-Checked $signTool @( + 'sign', '/v', '/fd', 'SHA256', '/f', $pfxPath, '/p', $password, $driverBinaryPath + ) + Invoke-Checked inf2cat @( + "/driver:$packageRoot", '/os:10_X64', '/uselocaltime' + ) Invoke-Checked $signTool @( 'sign', '/v', '/fd', 'SHA256', '/f', $pfxPath, '/p', $password, $catalogPath ) From 236dcc88d2d149ee64976d0aa42086a2ebe71c4f Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Sat, 22 Aug 2026 14:16:22 -0700 Subject: [PATCH 33/39] Isolate each CatHub UMDF device host --- crates/cathub/src/config.rs | 8 +-- docs/design/virtual-serial-umdf-poc.md | 65 ++++++++++--------- drivers/cathub-virtual-serial-umdf/README.md | 2 + .../cathub_virtual_serial_umdf.inx | 6 ++ .../src/data_plane.rs | 10 +-- .../cathub-virtual-serial-umdf/src/interop.rs | 10 +-- drivers/cathub-virtual-serial-umdf/src/lib.rs | 9 +-- 7 files changed, 61 insertions(+), 49 deletions(-) diff --git a/crates/cathub/src/config.rs b/crates/cathub/src/config.rs index 8f2981c..b1b9ccd 100644 --- a/crates/cathub/src/config.rs +++ b/crates/cathub/src/config.rs @@ -138,13 +138,13 @@ impl Default for EventsConfig { pub(crate) struct SerialEndpointConfig { /// A label for logging. pub(crate) name: String, - /// The serial port this endpoint listens on (a com0com / tty path). + /// An externally provisioned serial port this endpoint listens on. #[serde(default)] pub(crate) transport: String, /// Stable endpoint identifier exposed by the CatHub UMDF driver. #[serde(default)] pub(crate) virtual_endpoint: Option, - /// The paired endpoint opened by the client application. The hub never opens it. + /// The application-facing COM port, recorded for provisioning and migration guidance. #[serde(default)] pub(crate) application_transport: Option, /// Baud rate for the endpoint port. @@ -227,13 +227,13 @@ pub(crate) struct WinkeyerConfig { pub(crate) struct WinkeyerEndpointConfig { /// Stable endpoint name used in logs and ownership status. pub(crate) name: String, - /// Hub side of the virtual serial pair. + /// An externally provisioned serial port used by the hub side. #[serde(default)] pub(crate) transport: String, /// Stable endpoint identifier exposed by the CatHub UMDF driver. #[serde(default)] pub(crate) virtual_endpoint: Option, - /// Paired endpoint opened by the client application. The hub never opens it. + /// The application-facing COM port, recorded for provisioning and migration guidance. #[serde(default)] pub(crate) application_transport: Option, /// Virtual endpoint baud rate. diff --git a/docs/design/virtual-serial-umdf-poc.md b/docs/design/virtual-serial-umdf-poc.md index bba88e3..1c1dbfa 100644 --- a/docs/design/virtual-serial-umdf-poc.md +++ b/docs/design/virtual-serial-umdf-poc.md @@ -2,17 +2,15 @@ ## Scope and status -This is the isolated proof-of-concept branch for issue #6. -Phase 1 defined the private framing contract and conformance harness. -This milestone pins the pure Rust UMDF 2 binary and adds its first private byte-transfer channel -before any device is installed. - -The scaffold is not a virtual COM driver yet. -Its INF uses a private CatHub proof-of-concept class. The driver registers `application` and -`daemon` reference names under private interface -`{0084BDDE-9F40-4A6A-AF84-0F4E46B70901}` and transfers raw bytes between them. -Keeping the device non-serial prevents an incomplete driver from appearing usable to N1MM or -another station application. +This branch began as the isolated proof of concept for issue #6 and now contains the integrated +development candidate. Phase 1 defined the private framing contract and conformance harness; the +subsequent work added the pure Rust UMDF data plane, CatHub adapter, Ports-class provisioning, and +development packaging. + +The driver now registers one application-facing `GUID_DEVINTERFACE_COMPORT` interface and one +reference-named `daemon` instance of the private CatHub interface +`{0084BDDE-9F40-4A6A-AF84-0F4E46B70901}`. The application sees one Windows-assigned COM port while +`cathub.exe` transfers framed data and control events through the private interface. ## Reproducible inputs @@ -33,7 +31,7 @@ WDK configuration in a Cargo build graph. ## Local environment audit -Audit date: 2026-08-15. +Audit date: 2026-08-22. Available on the development station: @@ -43,12 +41,18 @@ Available on the development station: - Windows SDK directories through 10.0.26100.0 - User-local Microsoft WDK NuGet package 10.0.28000.2526 and SDK dependency 10.0.28000.1721 - `cargo-make` 0.37.24 and `rust-script` 0.36.0 -- N1MM Logger+ and isolated com0com pairs including COM20/COM21 +- N1MM Logger+ and the existing com0com pairs, which remain untouched for comparison The WDK package supplies the WDF headers and WDK validation/packaging tools without a machine-wide installation or administrator access. The driver compiles against that package, `Inf2Cat` reports -zero signability errors or warnings, and `InfVerif` accepts the generated INF. No driver or -certificate was installed during this audit; the validated package is unsigned. +zero signability errors or warnings, and `InfVerif` accepts the generated INF. The package embeds +a short-lived development signature in the DLL before regenerating and signing the catalog. + +With explicit operator authorization, the development package was provisioned locally as COM91 +without changing or removing any com0com device. Windows staged the package and verified both +signatures, but normal boot policy rejected the self-signed image with +`ERROR_INVALID_IMAGE_HASH`. Installed functional validation therefore awaits an explicitly +authorized Test Signing reboot or a publicly/Microsoft-signed package. ## Microsoft sample inventory @@ -60,12 +64,12 @@ The source remains upstream; CatHub does not copy the C implementation. | Area | VirtualSerial2 behavior | CatHub PoC state | |---|---|---| -| Driver | `DriverEntry`, `EVT_WDF_DRIVER_DEVICE_ADD` | Entry and device-add skeleton | +| Driver | `DriverEntry`, `EVT_WDF_DRIVER_DEVICE_ADD` | Pure Rust entry and device creation implemented | | Device | Device context and cleanup callback | Typed per-device context and destroy cleanup implemented | -| Default queue | Parallel read, write, and device-control callbacks | Sequential proof-of-concept read/write queue implemented | +| Default queue | Parallel read, write, and device-control callbacks | Sequential read, write, and serial-control queue implemented | | Pending reads | Manual queue | Two manual queues with cancellation and disconnect draining implemented | -| Pending event wait | Separate manual queue | Planned, one outstanding wait policy required | -| Cleanup | Device cleanup releases COM mapping | Planned with daemon detach and fail-safe revocation | +| Pending event wait | Separate manual queue | One-outstanding-wait manual queue implemented | +| Cleanup | Device cleanup releases COM mapping | Application/daemon detach drains requests and clears bounded buffers | ### Serial controls implemented by the sample @@ -119,15 +123,14 @@ byte buffers, exclusive-handle state, application session sequence, and pending- Each device owns those values through typed WDF context; file and queue callbacks resolve their parent device before accessing the state. -## Gates before the INF becomes a Ports-class package - -1. Restore the pinned WDK package in the isolated development environment. -2. Build and package this proof-of-concept-class driver with warnings treated as errors. -3. Keep endpoint state and queue handles in typed per-device context, resolving it from file and - queue callbacks. -4. Extend the implemented bounded read/write, cancellation, and cleanup behavior with serial - timeout state. -5. Register `GUID_DEVINTERFACE_COMPORT` and a private CatHub interface. -6. Add the COM mapping only after restart and removal are deterministic. -7. Install only on the isolated test target with development-signing policy documented. -8. Run the `n1mm-radio` sequence in `docs/testing/n1mm-radio-poc.md`. +## Remaining installed-validation gates + +1. Load the self-signed development image under explicitly authorized Windows Test Signing policy, + or obtain a public/Microsoft signature suitable for normal policy. +2. Confirm the device starts, both interfaces enumerate, and the stable COM mapping survives a + clean device restart. +3. Run every native serial conformance profile through the private CatHub adapter. +4. Run the `n1mm-radio` sequence in `docs/testing/n1mm-radio-poc.md` and repeat the applicable + flows for the other four legacy clients. +5. Exercise daemon failure, UMDF-host restart, repair, upgrade, rollback, removal, sleep, and resume + while preserving fail-safe transmit behavior. diff --git a/drivers/cathub-virtual-serial-umdf/README.md b/drivers/cathub-virtual-serial-umdf/README.md index 766c5b4..8b27e51 100644 --- a/drivers/cathub-virtual-serial-umdf/README.md +++ b/drivers/cathub-virtual-serial-umdf/README.md @@ -78,6 +78,8 @@ test certificate off normal operator machines. All Windows and WDF calls live in `src/interop.rs`. Every exported or registered callback catches Rust panics before they can unwind into WDF. The driver contains no kernel-mode CatHub code and no C or C++ shim. +Each device stack opts out of UMDF device pooling so a CatHub endpoint runs in its own +`WUDFHost.exe` process instead of sharing an address space with unrelated UMDF drivers. The data plane currently provides: diff --git a/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx b/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx index f988911..60ec08f 100644 --- a/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx +++ b/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx @@ -48,6 +48,7 @@ Needs=WUDFRD.NT.Services [CatHubUMDFDevice_Install.NT.Wdf] UmdfService=cathub_virtual_serial_umdf, CatHubUMDFDevice_WdfInstall UmdfServiceOrder=cathub_virtual_serial_umdf +UmdfHostProcessSharing=ProcessSharingDisabled UmdfKernelModeClientPolicy=AllowKernelModeClients UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects UmdfFsContextUsePolicy=CannotUseFsContexts @@ -69,6 +70,7 @@ Needs=WUDFRD.NT.Services [CatHubHdsdr_Install.NT.Wdf] UmdfService=cathub_virtual_serial_umdf, CatHubUMDFDevice_WdfInstall UmdfServiceOrder=cathub_virtual_serial_umdf +UmdfHostProcessSharing=ProcessSharingDisabled UmdfKernelModeClientPolicy=AllowKernelModeClients UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects UmdfFsContextUsePolicy=CannotUseFsContexts @@ -90,6 +92,7 @@ Needs=WUDFRD.NT.Services [CatHubN1mmCat_Install.NT.Wdf] UmdfService=cathub_virtual_serial_umdf, CatHubUMDFDevice_WdfInstall UmdfServiceOrder=cathub_virtual_serial_umdf +UmdfHostProcessSharing=ProcessSharingDisabled UmdfKernelModeClientPolicy=AllowKernelModeClients UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects UmdfFsContextUsePolicy=CannotUseFsContexts @@ -111,6 +114,7 @@ Needs=WUDFRD.NT.Services [CatHubArcp_Install.NT.Wdf] UmdfService=cathub_virtual_serial_umdf, CatHubUMDFDevice_WdfInstall UmdfServiceOrder=cathub_virtual_serial_umdf +UmdfHostProcessSharing=ProcessSharingDisabled UmdfKernelModeClientPolicy=AllowKernelModeClients UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects UmdfFsContextUsePolicy=CannotUseFsContexts @@ -132,6 +136,7 @@ Needs=WUDFRD.NT.Services [CatHubN1mmWinkeyer_Install.NT.Wdf] UmdfService=cathub_virtual_serial_umdf, CatHubUMDFDevice_WdfInstall UmdfServiceOrder=cathub_virtual_serial_umdf +UmdfHostProcessSharing=ProcessSharingDisabled UmdfKernelModeClientPolicy=AllowKernelModeClients UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects UmdfFsContextUsePolicy=CannotUseFsContexts @@ -153,6 +158,7 @@ Needs=WUDFRD.NT.Services [CatHubWktools_Install.NT.Wdf] UmdfService=cathub_virtual_serial_umdf, CatHubUMDFDevice_WdfInstall UmdfServiceOrder=cathub_virtual_serial_umdf +UmdfHostProcessSharing=ProcessSharingDisabled UmdfKernelModeClientPolicy=AllowKernelModeClients UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects UmdfFsContextUsePolicy=CannotUseFsContexts diff --git a/drivers/cathub-virtual-serial-umdf/src/data_plane.rs b/drivers/cathub-virtual-serial-umdf/src/data_plane.rs index 2221a3e..5d786a5 100644 --- a/drivers/cathub-virtual-serial-umdf/src/data_plane.rs +++ b/drivers/cathub-virtual-serial-umdf/src/data_plane.rs @@ -1,16 +1,16 @@ -//! Safe, bounded byte transport between the two proof-of-concept handles. +//! Safe, bounded byte transport between the application COM and daemon handles. use std::collections::VecDeque; /// Maximum bytes retained in each direction. pub const DEFAULT_BUFFER_CAPACITY: usize = 64 * 1024; -/// Side of the proof-of-concept device channel. +/// Side of one managed endpoint's device channel. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChannelRole { - /// Handle that stands in for the future application COM port. + /// Public application COM handle. Application, - /// Private handle owned by the CatHub-side test process. + /// Private handle owned by the `CatHub` daemon. Daemon, } @@ -43,7 +43,7 @@ pub enum DataPlaneError { }, } -/// One endpoint's in-memory proof-of-concept transport state. +/// One endpoint's in-memory transport state. #[derive(Debug)] pub struct EndpointDataPlane { application_open: bool, diff --git a/drivers/cathub-virtual-serial-umdf/src/interop.rs b/drivers/cathub-virtual-serial-umdf/src/interop.rs index b210439..238ccce 100644 --- a/drivers/cathub-virtual-serial-umdf/src/interop.rs +++ b/drivers/cathub-virtual-serial-umdf/src/interop.rs @@ -71,7 +71,7 @@ const DOS_DEVICE_PREFIX: &[u16] = &[ 92, 68, 111, 115, 68, 101, 118, 105, 99, 101, 115, 92, 71, 108, 111, 98, 97, 108, 92, ]; -/// Private proof-of-concept interface, `{0084BDDE-9F40-4A6A-AF84-0F4E46B70901}`. +/// Private `CatHub` daemon interface, `{0084BDDE-9F40-4A6A-AF84-0F4E46B70901}`. static CATHUB_POC_INTERFACE_GUID: GUID = GUID { Data1: 0x0084_BDDE, Data2: 0x9F40, @@ -266,7 +266,7 @@ pub unsafe extern "system" fn driver_entry( } unsafe fn driver_entry_inner(driver: PDRIVER_OBJECT, registry_path: PCUNICODE_STRING) -> NTSTATUS { - println!("CatHub UMDF proof-of-concept DriverEntry"); + println!("CatHub virtual serial UMDF DriverEntry"); // SAFETY: DriverEntry runs before WDF can create device objects or invoke callbacks. unsafe { initialize_device_context_type() }; let mut driver_config = WDF_DRIVER_CONFIG { @@ -758,7 +758,7 @@ unsafe extern "C" fn evt_device_file_create( state.channel().close_if_owner(role, file); return RequestDisposition::error(status_for_error(error)); } - println!("CatHub PoC {role:?} handle opened (session {session})"); + println!("CatHub {role:?} handle opened (session {session})"); RequestDisposition::success(0) } Err(error) => RequestDisposition::error(status_for_error(error)), @@ -836,7 +836,7 @@ unsafe extern "C" fn evt_file_cleanup(file: WDFFILEOBJECT) { } }; if state.channel().close_if_owner(role, file) { - println!("CatHub PoC {role:?} handle cleaned up"); + println!("CatHub {role:?} handle cleaned up"); state.pending_application_reads().clear(); // SAFETY: Cleanup runs while the device and its child queues are alive. let _ = unsafe { apply_protocol_outputs(state, outputs) }; @@ -1825,7 +1825,7 @@ fn unicode_string_buffer(buffer: &mut [u16]) -> UNICODE_STRING { } unsafe extern "C" fn evt_driver_unload(_driver: WDFDRIVER) { - ffi_void(|| println!("CatHub UMDF proof-of-concept unloaded")); + ffi_void(|| println!("CatHub virtual serial UMDF unloaded")); } fn ffi_status(action: impl FnOnce() -> NTSTATUS) -> NTSTATUS { diff --git a/drivers/cathub-virtual-serial-umdf/src/lib.rs b/drivers/cathub-virtual-serial-umdf/src/lib.rs index 51e10ae..dccdbe9 100644 --- a/drivers/cathub-virtual-serial-umdf/src/lib.rs +++ b/drivers/cathub-virtual-serial-umdf/src/lib.rs @@ -1,11 +1,12 @@ // Copyright (c) CatHub contributors. // SPDX-License-Identifier: MIT -//! Pure Rust UMDF 2 proof of concept for `CatHub` virtual serial endpoints. +//! Pure Rust UMDF 2 driver for `CatHub`-managed virtual serial endpoints. //! -//! The current milestone creates a private, reference-named application/daemon -//! interface with bounded bidirectional queues. It deliberately does not yet -//! register a public COM port. +//! Each device exposes one public Windows COM port and one ACL-restricted, +//! reference-named private interface used by the `CatHub` daemon. The driver +//! owns bounded bidirectional queues and implements the Windows serial control +//! surface exercised by the native conformance harness. #[cfg_attr(test, allow(dead_code))] mod data_plane; From 0e4aabf9877f206dfb56e9929a6f3c75dbab83d5 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Sat, 22 Aug 2026 14:22:13 -0700 Subject: [PATCH 34/39] Register managed ports with Windows serial discovery --- docs/design/virtual-serial-umdf-poc.md | 3 +- drivers/cathub-virtual-serial-umdf/README.md | 5 +- .../cathub-virtual-serial-umdf/src/interop.rs | 185 ++++++++++++++++-- scripts/Test-UmdfEndToEnd.ps1 | 32 ++- 4 files changed, 206 insertions(+), 19 deletions(-) diff --git a/docs/design/virtual-serial-umdf-poc.md b/docs/design/virtual-serial-umdf-poc.md index 1c1dbfa..acc331c 100644 --- a/docs/design/virtual-serial-umdf-poc.md +++ b/docs/design/virtual-serial-umdf-poc.md @@ -110,7 +110,8 @@ The unsafe surface remains isolated in one module, `src/interop.rs`: - `WdfDriverCreate` - device-add callback and `WdfDeviceCreate` - file create and cleanup callbacks -- private device-interface registration +- public COM and private device-interface registration +- global COM symbolic-link and legacy `SERIALCOMM` device-map lifecycle - default and manual queue creation - typed device-context registration, lookup, and destroy cleanup - request forwarding, retrieval, cancellation, and completion diff --git a/drivers/cathub-virtual-serial-umdf/README.md b/drivers/cathub-virtual-serial-umdf/README.md index 8b27e51..bc50e09 100644 --- a/drivers/cathub-virtual-serial-umdf/README.md +++ b/drivers/cathub-virtual-serial-umdf/README.md @@ -6,8 +6,9 @@ configuration per Cargo build graph. The current driver installs as a Ports-class WDF device, registers `GUID_DEVINTERFACE_COMPORT`, uses the COM number assigned by the Windows Ports class installer, and creates the corresponding -global `COMx` symbolic link. It also registers the reference-named `daemon` instance of the private -CatHub interface `{0084BDDE-9F40-4A6A-AF84-0F4E46B70901}`. +global `COMx` symbolic link and `HARDWARE\DEVICEMAP\SERIALCOMM` entry used by legacy enumerators. +It also registers the reference-named `daemon` instance of the private CatHub interface +`{0084BDDE-9F40-4A6A-AF84-0F4E46B70901}`. Do not install it on the working station. ## Pinned inputs diff --git a/drivers/cathub-virtual-serial-umdf/src/interop.rs b/drivers/cathub-virtual-serial-umdf/src/interop.rs index 238ccce..2b739ce 100644 --- a/drivers/cathub-virtual-serial-umdf/src/interop.rs +++ b/drivers/cathub-virtual-serial-umdf/src/interop.rs @@ -15,14 +15,14 @@ use std::{ use wdk::println; use wdk_sys::{ - _SECURITY_IMPERSONATION_LEVEL, _WDF_EXECUTION_LEVEL, _WDF_FILEOBJECT_CLASS, - _WDF_IO_QUEUE_DISPATCH_TYPE, _WDF_SYNCHRONIZATION_SCOPE, _WDF_TRI_STATE, BOOLEAN, GUID, - KEY_QUERY_VALUE, KEY_SET_VALUE, NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, - PLUGPLAY_REGKEY_DEVICE, PVOID, ULONG, ULONG_PTR, UNICODE_STRING, WDF_DRIVER_CONFIG, - WDF_FILEOBJECT_CONFIG, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, - WDF_OBJECT_ATTRIBUTES, WDF_OBJECT_CONTEXT_TYPE_INFO, WDF_TIMER_CONFIG, WDFDEVICE, - WDFDEVICE_INIT, WDFDRIVER, WDFFILEOBJECT, WDFKEY, WDFOBJECT, WDFQUEUE, WDFQUEUE__, WDFREQUEST, - WDFTIMER, call_unsafe_wdf_function_binding, + _POOL_TYPE, _SECURITY_IMPERSONATION_LEVEL, _WDF_EXECUTION_LEVEL, _WDF_FILEOBJECT_CLASS, + _WDF_IO_QUEUE_DISPATCH_TYPE, _WDF_SYNCHRONIZATION_SCOPE, _WDF_TRI_STATE, BOOLEAN, + DEVICE_REGISTRY_PROPERTY, GUID, KEY_QUERY_VALUE, KEY_SET_VALUE, NTSTATUS, PCUNICODE_STRING, + PDRIVER_OBJECT, PLUGPLAY_REGKEY_DEVICE, PVOID, ULONG, ULONG_PTR, UNICODE_STRING, + WDF_DRIVER_CONFIG, WDF_FILEOBJECT_CONFIG, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, + WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES, WDF_OBJECT_CONTEXT_TYPE_INFO, + WDF_TIMER_CONFIG, WDFDEVICE, WDFDEVICE_INIT, WDFDRIVER, WDFFILEOBJECT, WDFKEY, WDFMEMORY, + WDFOBJECT, WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER, call_unsafe_wdf_function_binding, }; use windows_sys::Win32::Security::{CheckTokenMembership, GetLengthSid, IsValidSid}; @@ -70,6 +70,7 @@ const STARTUP_STATUS_VALUE: [u16; 20] = [ const DOS_DEVICE_PREFIX: &[u16] = &[ 92, 68, 111, 115, 68, 101, 118, 105, 99, 101, 115, 92, 71, 108, 111, 98, 97, 108, 92, ]; +const SERIAL_DEVICE_MAP: [u16; 11] = [83, 69, 82, 73, 65, 76, 67, 79, 77, 77, 0]; /// Private `CatHub` daemon interface, `{0084BDDE-9F40-4A6A-AF84-0F4E46B70901}`. static CATHUB_POC_INTERFACE_GUID: GUID = GUID { @@ -90,6 +91,8 @@ static GUID_DEVINTERFACE_COMPORT: GUID = GUID { #[repr(C)] struct DeviceContext { state: *mut DeviceState, + pdo_name: UNICODE_STRING, + legacy_serial_map_created: bool, } struct DeviceState { @@ -320,6 +323,7 @@ unsafe fn create_device(mut device_init: *mut WDFDEVICE_INIT) -> NTSTATUS { let mut device_attributes = WDF_OBJECT_ATTRIBUTES { Size: struct_size::(), + EvtCleanupCallback: Some(evt_device_cleanup), EvtDestroyCallback: Some(evt_device_context_destroy), ExecutionLevel: _WDF_EXECUTION_LEVEL::WdfExecutionLevelPassive, SynchronizationScope: _WDF_SYNCHRONIZATION_SCOPE::WdfSynchronizationScopeNone, @@ -534,7 +538,17 @@ unsafe fn register_interfaces(device: WDFDEVICE) -> NTSTATUS { return status; } // SAFETY: The device instance key and PortName value are owned by Windows Ports setup. - let status = unsafe { create_com_symbolic_link(device) }; + let port_name = match unsafe { read_port_name(device) } { + Ok(port_name) => port_name, + Err(status) => return status, + }; + // SAFETY: The device is live and the port name remains valid through the synchronous call. + let status = unsafe { create_com_symbolic_link(device, &port_name) }; + if !nt_success(status) { + return status; + } + // SAFETY: The device and port-name buffer remain live through registration. + let status = unsafe { register_legacy_serial_map(device, &port_name) }; if !nt_success(status) { return status; } @@ -662,7 +676,7 @@ unsafe fn query_registry_binary(key: WDFKEY, value: &[u16], maximum: usize) -> O (nt_success(status) && value_type == 3).then_some(buffer) } -unsafe fn create_com_symbolic_link(device: WDFDEVICE) -> NTSTATUS { +unsafe fn read_port_name(device: WDFDEVICE) -> Result, NTSTATUS> { let mut key: WDFKEY = ptr::null_mut(); // SAFETY: The device is live, null attributes are allowed, and output storage is valid. let status = unsafe { @@ -676,7 +690,7 @@ unsafe fn create_com_symbolic_link(device: WDFDEVICE) -> NTSTATUS { ) }; if !nt_success(status) { - return status; + return Err(status); } let value_name = unicode_string(&PORT_NAME_VALUE); @@ -695,15 +709,23 @@ unsafe fn create_com_symbolic_link(device: WDFDEVICE) -> NTSTATUS { // SAFETY: This driver owns the WDF registry-key handle returned above. unsafe { call_unsafe_wdf_function_binding!(WdfRegistryClose, key) }; if !nt_success(query_status) { - return query_status; + return Err(query_status); } let port_units = usize::from(port_name.Length) / size_of::(); if port_units == 0 || port_units >= port_buffer.len() { - return STATUS_OBJECT_NAME_INVALID; + return Err(STATUS_OBJECT_NAME_INVALID); } + let mut port_name = Vec::with_capacity(port_units + 1); + port_name.extend_from_slice(port_buffer.get(..port_units).unwrap_or_default()); + port_name.push(0); + Ok(port_name) +} + +unsafe fn create_com_symbolic_link(device: WDFDEVICE, port_name: &[u16]) -> NTSTATUS { + let port_units = port_name.len().saturating_sub(1); let mut link = Vec::with_capacity(DOS_DEVICE_PREFIX.len() + port_units + 1); link.extend_from_slice(DOS_DEVICE_PREFIX); - link.extend_from_slice(port_buffer.get(..port_units).unwrap_or_default()); + link.extend_from_slice(port_name.get(..port_units).unwrap_or_default()); link.push(0); let symbolic_link = unicode_string(&link); // SAFETY: The device is live and the counted link string remains valid synchronously. @@ -716,6 +738,102 @@ unsafe fn create_com_symbolic_link(device: WDFDEVICE) -> NTSTATUS { } } +unsafe fn register_legacy_serial_map(device: WDFDEVICE, port_name: &[u16]) -> NTSTATUS { + // SAFETY: The caller supplies the live device created with this driver's typed context. + let Some(context) = (unsafe { device_context(device) }) else { + return STATUS_UNSUCCESSFUL; + }; + let mut memory_attributes = WDF_OBJECT_ATTRIBUTES { + Size: struct_size::(), + ParentObject: device.cast(), + ..WDF_OBJECT_ATTRIBUTES::default() + }; + let mut memory: WDFMEMORY = ptr::null_mut(); + // SAFETY: WDF owns the device and creates device-parented storage for the PDO name. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceAllocAndQueryProperty, + device, + DEVICE_REGISTRY_PROPERTY::DevicePropertyPhysicalDeviceObjectName, + _POOL_TYPE::NonPagedPoolNx, + &raw mut memory_attributes, + &raw mut memory, + ) + }; + if !nt_success(status) { + return status; + } + let mut byte_length = 0_usize; + // SAFETY: The successful property query returned a live device-parented memory object. + let buffer = unsafe { + call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, memory, &raw mut byte_length) + }; + if buffer.is_null() + || byte_length < size_of::() + || !byte_length.is_multiple_of(size_of::()) + { + return STATUS_OBJECT_NAME_INVALID; + } + let units = byte_length / size_of::(); + // SAFETY: WDF reports the exact allocation length and the property is a UTF-16 string. + let pdo_buffer = unsafe { slice::from_raw_parts_mut(buffer.cast::(), units) }; + let content_units = pdo_buffer + .iter() + .position(|unit| *unit == 0) + .unwrap_or(pdo_buffer.len()); + if content_units == 0 || content_units >= pdo_buffer.len() { + return STATUS_OBJECT_NAME_INVALID; + } + let content_bytes = content_units.saturating_mul(size_of::()); + let maximum_bytes = (content_units + 1).saturating_mul(size_of::()); + let pdo_name = UNICODE_STRING { + Length: u16::try_from(content_bytes).unwrap_or(u16::MAX), + MaximumLength: u16::try_from(maximum_bytes).unwrap_or(u16::MAX), + Buffer: pdo_buffer.as_mut_ptr(), + }; + if usize::from(pdo_name.Length) != content_bytes + || usize::from(pdo_name.MaximumLength) != maximum_bytes + { + return STATUS_OBJECT_NAME_INVALID; + } + + let map_name = unicode_string(&SERIAL_DEVICE_MAP); + let mut key: WDFKEY = ptr::null_mut(); + // SAFETY: WDF opens the system device-map key and returns an owned key handle. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceOpenDevicemapKey, + device, + &raw const map_name, + KEY_SET_VALUE, + WDF_NO_OBJECT_ATTRIBUTES, + &raw mut key, + ) + }; + if !nt_success(status) { + return status; + } + let com_name = unicode_string(port_name); + // SAFETY: Both counted strings and the key remain live through the synchronous assignment. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfRegistryAssignUnicodeString, + key, + &raw const pdo_name, + &raw const com_name, + ) + }; + // SAFETY: This function owns the WDF registry-key handle returned above. + unsafe { call_unsafe_wdf_function_binding!(WdfRegistryClose, key) }; + if nt_success(status) { + // SAFETY: The device context is exclusively initialized before interfaces are published. + unsafe { (*context).pdo_name = pdo_name }; + // SAFETY: The device context is exclusively initialized before interfaces are published. + unsafe { (*context).legacy_serial_map_created = true }; + } + status +} + unsafe extern "C" fn evt_device_file_create( device: WDFDEVICE, request: WDFREQUEST, @@ -1750,6 +1868,45 @@ unsafe fn device_state_from_queue<'device>(queue: WDFQUEUE) -> Option<&'device D unsafe { device_state(device) } } +unsafe extern "C" fn evt_device_cleanup(object: WDFOBJECT) { + ffi_void(|| { + let device: WDFDEVICE = object.cast(); + // SAFETY: WDF invokes cleanup while the device context and device-parented PDO-name memory + // remain live, matching the lifetime used by the VirtualSerial2 reference driver. + let Some(context) = (unsafe { device_context(device) }) else { + return; + }; + // SAFETY: This callback is the sole remover and the context remains live for its duration. + if !unsafe { (*context).legacy_serial_map_created } { + return; + } + let map_name = unicode_string(&SERIAL_DEVICE_MAP); + let mut key: WDFKEY = ptr::null_mut(); + // SAFETY: The device is still valid during its cleanup callback and output storage is live. + let status = unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceOpenDevicemapKey, + device, + &raw const map_name, + KEY_SET_VALUE, + WDF_NO_OBJECT_ATTRIBUTES, + &raw mut key, + ) + }; + if !nt_success(status) { + return; + } + // SAFETY: Successful registration stored this counted string in device-parented memory. + let pdo_name = unsafe { &raw const (*context).pdo_name }; + // SAFETY: Remove only this device's value; never remove the shared SERIALCOMM key. + let _ = unsafe { call_unsafe_wdf_function_binding!(WdfRegistryRemoveValue, key, pdo_name) }; + // SAFETY: This callback owns the WDF registry-key handle returned above. + unsafe { call_unsafe_wdf_function_binding!(WdfRegistryClose, key) }; + // SAFETY: Cleanup runs once before the context is destroyed. + unsafe { (*context).legacy_serial_map_created = false }; + }); +} + unsafe extern "C" fn evt_device_context_destroy(object: WDFOBJECT) { ffi_void(|| { // SAFETY: WDF calls this for the device object whose context is being destroyed. diff --git a/scripts/Test-UmdfEndToEnd.ps1 b/scripts/Test-UmdfEndToEnd.ps1 index 9cd29c7..9b31e73 100644 --- a/scripts/Test-UmdfEndToEnd.ps1 +++ b/scripts/Test-UmdfEndToEnd.ps1 @@ -395,6 +395,27 @@ try { $results.pnp_after_install = Get-PnpEvidence -InstanceId $device.InstanceId $publishedInf = $results.pnp_after_install.properties['DEVPKEY_Device_DriverInfPath'] + $serialMapKey = Get-Item -LiteralPath ` + 'Registry::HKEY_LOCAL_MACHINE\HARDWARE\DEVICEMAP\SERIALCOMM' ` + -ErrorAction Stop + $serialMapValues = @( + $serialMapKey.GetValueNames() | + Where-Object { $serialMapKey.GetValue($_) -eq $portName } + ) + if ($serialMapValues.Count -ne 1) { + throw "Expected exactly one SERIALCOMM registration for '$portName', found $($serialMapValues.Count)." + } + $enumeratedPorts = @([System.IO.Ports.SerialPort]::GetPortNames()) + if ($portName -notin $enumeratedPorts) { + throw "System.IO.Ports did not enumerate the CatHub port '$portName'." + } + $results.cases += [ordered]@{ + name = 'windows_serial_port_discovery' + passed = $true + serialcomm_value = $serialMapValues[0] + system_io_ports = $enumeratedPorts + } + $portProbe = [System.Net.Sockets.TcpListener]::new( [System.Net.IPAddress]::Loopback, 0 @@ -419,7 +440,14 @@ try { } $results.serial_conformance = [ordered]@{} - foreach ($profile in @('n1mm-radio', 'n1mm-winkeyer')) { + $conformanceProfiles = @( + 'hdsdr-omnirig', + 'n1mm-radio', + 'arcp-590', + 'n1mm-winkeyer', + 'wktools' + ) + foreach ($profile in $conformanceProfiles) { $reportPath = Join-Path $workRoot "serial-conformance-$profile.json" $run = Invoke-Captured $ConformanceExe @( 'run', @@ -443,7 +471,7 @@ try { $results.cases += [ordered]@{ name = 'native_serial_api_conformance' passed = $true - profiles = @('n1mm-radio', 'n1mm-winkeyer') + profiles = $conformanceProfiles } $process = Start-Process -FilePath $CatHubExe ` From a244d92a637cf6fde7031740786eaeed4b7b0bc6 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Sat, 22 Aug 2026 14:24:12 -0700 Subject: [PATCH 35/39] Verify serial flow control round trips --- .../src/conformance/windows.rs | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/cathub-virtual-serial/src/conformance/windows.rs b/crates/cathub-virtual-serial/src/conformance/windows.rs index 223048e..8d4ff2f 100644 --- a/crates/cathub-virtual-serial/src/conformance/windows.rs +++ b/crates/cathub-virtual-serial/src/conformance/windows.rs @@ -31,6 +31,17 @@ use crate::TEST_PEER_READY; const IO_WAIT_MS: u32 = 2_000; const TEST_DATA: &[u8] = b"CatHub serial conformance"; const DRIVER_BUFFER_CAPACITY: usize = 64 * 1024; +const DCB_FLOW_CONTROL_MASK: u32 = (1 << 2) + | (1 << 3) + | (3 << 4) + | (1 << 6) + | (1 << 7) + | (1 << 8) + | (1 << 9) + | (1 << 10) + | (1 << 11) + | (3 << 12) + | (1 << 14); #[derive(Clone, Copy)] enum PeerTarget<'a> { @@ -323,9 +334,9 @@ fn serial_configuration( let winkeyer = matches!(profile.name, "n1mm-winkeyer" | "wktools"); let command = if winkeyer { - "baud=1200 parity=N data=8 stop=2" + "baud=1200 parity=N data=8 stop=2 xon=on octs=on odsr=off dtr=on rts=hs" } else { - "baud=9600 parity=N data=8 stop=1" + "baud=9600 parity=N data=8 stop=1 xon=on octs=on odsr=off dtr=on rts=hs" }; let mut proposed = original; let wide = wide(command); @@ -348,13 +359,25 @@ fn serial_configuration( || observed.ByteSize != 8 || observed.Parity != NOPARITY || observed.StopBits != expected_stop + || observed._bitfield & DCB_FLOW_CONTROL_MASK + != proposed._bitfield & DCB_FLOW_CONTROL_MASK + || observed.XonLim != proposed.XonLim + || observed.XoffLim != proposed.XoffLim + || observed.XonChar != proposed.XonChar + || observed.XoffChar != proposed.XoffChar { return Err(ConformanceError::InvalidResult(format!( - "observed format baud={} data={} parity={} stop={}", - observed.BaudRate, observed.ByteSize, observed.Parity, observed.StopBits + "observed format/flow baud={} data={} parity={} stop={} flags=0x{:08x}", + observed.BaudRate, + observed.ByteSize, + observed.Parity, + observed.StopBits, + observed._bitfield & DCB_FLOW_CONTROL_MASK ))); } - Ok(format!("serial format accepted: {command}")) + Ok(format!( + "serial format and flow control accepted: {command}" + )) })(); let _ = unsafe { SetCommState(application.handle(), &original) }; From be850dc72f44e9986408c9ee5a7f2ac22ce572a6 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Sat, 22 Aug 2026 14:25:36 -0700 Subject: [PATCH 36/39] Broaden native serial control conformance --- .../src/conformance/windows.rs | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/crates/cathub-virtual-serial/src/conformance/windows.rs b/crates/cathub-virtual-serial/src/conformance/windows.rs index 8d4ff2f..e5c89c1 100644 --- a/crates/cathub-virtual-serial/src/conformance/windows.rs +++ b/crates/cathub-virtual-serial/src/conformance/windows.rs @@ -7,10 +7,11 @@ use std::ptr::{null, null_mut}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use windows_sys::Win32::Devices::Communication::{ - BuildCommDCBW, ClearCommBreak, ClearCommError, EscapeCommFunction, GetCommState, - GetCommTimeouts, PurgeComm, SetCommBreak, SetCommMask, SetCommState, SetCommTimeouts, - WaitCommEvent, CLRDTR, CLRRTS, COMMTIMEOUTS, COMSTAT, DCB, EV_RXCHAR, NOPARITY, ONESTOPBIT, - PURGE_RXABORT, PURGE_RXCLEAR, SETDTR, SETRTS, TWOSTOPBITS, + BuildCommDCBW, ClearCommBreak, ClearCommError, EscapeCommFunction, GetCommMask, + GetCommModemStatus, GetCommProperties, GetCommState, GetCommTimeouts, PurgeComm, SetCommBreak, + SetCommMask, SetCommState, SetCommTimeouts, WaitCommEvent, CLRDTR, CLRRTS, COMMPROP, + COMMTIMEOUTS, COMSTAT, DCB, EV_RXCHAR, NOPARITY, ONESTOPBIT, PURGE_RXABORT, PURGE_RXCLEAR, + PURGE_TXABORT, PURGE_TXCLEAR, SETDTR, SETRTS, TWOSTOPBITS, }; use windows_sys::Win32::Foundation::{ CloseHandle, GetLastError, ERROR_IO_PENDING, ERROR_OPERATION_ABORTED, GENERIC_READ, @@ -279,7 +280,12 @@ fn purge_receive( "receive queue contains {after} bytes after purge" ))); } - Ok(format!("purge removed {before} queued bytes")) + if unsafe { PurgeComm(application.handle(), PURGE_TXABORT | PURGE_TXCLEAR) } == 0 { + return Err(last_error("PurgeComm transmit")); + } + Ok(format!( + "receive purge removed {before} queued bytes and transmit purge completed" + )) } fn wait_comm_event( @@ -291,6 +297,15 @@ fn wait_comm_event( if unsafe { SetCommMask(application.handle(), EV_RXCHAR) } == 0 { return Err(last_error("SetCommMask")); } + let mut configured_mask = 0_u32; + if unsafe { GetCommMask(application.handle(), &mut configured_mask) } == 0 { + return Err(last_error("GetCommMask")); + } + if configured_mask != EV_RXCHAR { + return Err(ConformanceError::InvalidResult(format!( + "GetCommMask returned 0x{configured_mask:08x}" + ))); + } let event = Event::new()?; let mut overlapped: OVERLAPPED = unsafe { zeroed() }; overlapped.hEvent = event.handle(); @@ -402,7 +417,13 @@ fn modem_control(application_port: &str) -> Result { if unsafe { ClearCommBreak(application.handle()) } == 0 { return Err(last_error("ClearCommBreak")); } - Ok("DTR, RTS, and break control requests completed".to_string()) + let mut modem_status = 0_u32; + if unsafe { GetCommModemStatus(application.handle(), &mut modem_status) } == 0 { + return Err(last_error("GetCommModemStatus")); + } + Ok(format!( + "DTR, RTS, and break controls completed; modem status 0x{modem_status:08x}" + )) } fn queue_status( @@ -412,6 +433,17 @@ fn queue_status( let application = Port::open(application_port, false)?; let mut peer = Peer::open(peer_target)?; set_timeout(application.handle(), 500)?; + let mut properties: COMMPROP = unsafe { zeroed() }; + if unsafe { GetCommProperties(application.handle(), &mut properties) } == 0 { + return Err(last_error("GetCommProperties")); + } + let expected_capacity = u32::try_from(DRIVER_BUFFER_CAPACITY).unwrap_or(u32::MAX); + if properties.dwMaxRxQueue < expected_capacity || properties.dwMaxTxQueue < expected_capacity { + return Err(ConformanceError::InvalidResult(format!( + "GetCommProperties reported rx={} tx={} bytes", + properties.dwMaxRxQueue, properties.dwMaxTxQueue + ))); + } peer.write_all(TEST_DATA)?; std::thread::sleep(Duration::from_millis(50)); let depth = queue_depth(application.handle())?; From 32641a2328d78e1e1505d9660db538c79102b9b9 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Sat, 22 Aug 2026 16:10:40 -0700 Subject: [PATCH 37/39] Complete UMDF serial transport end to end --- .../src/conformance/windows.rs | 37 ++--- crates/cathub/Cargo.toml | 1 + crates/cathub/src/managed_virtual_serial.rs | 126 ++++++++++++++++-- .../cathub_virtual_serial_umdf.inx | 7 +- .../cathub-virtual-serial-umdf/src/interop.rs | 124 ++++++++++++++--- .../cathub-virtual-serial-umdf/src/serial.rs | 34 ++++- scripts/Test-UmdfEndToEnd.ps1 | 75 +++++++++-- 7 files changed, 340 insertions(+), 64 deletions(-) diff --git a/crates/cathub-virtual-serial/src/conformance/windows.rs b/crates/cathub-virtual-serial/src/conformance/windows.rs index e5c89c1..f36657f 100644 --- a/crates/cathub-virtual-serial/src/conformance/windows.rs +++ b/crates/cathub-virtual-serial/src/conformance/windows.rs @@ -7,11 +7,11 @@ use std::ptr::{null, null_mut}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use windows_sys::Win32::Devices::Communication::{ - BuildCommDCBW, ClearCommBreak, ClearCommError, EscapeCommFunction, GetCommMask, - GetCommModemStatus, GetCommProperties, GetCommState, GetCommTimeouts, PurgeComm, SetCommBreak, - SetCommMask, SetCommState, SetCommTimeouts, WaitCommEvent, CLRDTR, CLRRTS, COMMPROP, - COMMTIMEOUTS, COMSTAT, DCB, EV_RXCHAR, NOPARITY, ONESTOPBIT, PURGE_RXABORT, PURGE_RXCLEAR, - PURGE_TXABORT, PURGE_TXCLEAR, SETDTR, SETRTS, TWOSTOPBITS, + ClearCommBreak, ClearCommError, EscapeCommFunction, GetCommMask, GetCommModemStatus, + GetCommProperties, GetCommState, GetCommTimeouts, PurgeComm, SetCommBreak, SetCommMask, + SetCommState, SetCommTimeouts, WaitCommEvent, CLRDTR, CLRRTS, COMMPROP, COMMTIMEOUTS, COMSTAT, + DCB, EV_RXCHAR, NOPARITY, ONESTOPBIT, PURGE_RXABORT, PURGE_RXCLEAR, PURGE_TXABORT, + PURGE_TXCLEAR, SETDTR, SETRTS, TWOSTOPBITS, }; use windows_sys::Win32::Foundation::{ CloseHandle, GetLastError, ERROR_IO_PENDING, ERROR_OPERATION_ABORTED, GENERIC_READ, @@ -43,6 +43,7 @@ const DCB_FLOW_CONTROL_MASK: u32 = (1 << 2) | (1 << 11) | (3 << 12) | (1 << 14); +const DESIRED_DCB_FLAGS: u32 = (1 << 0) | (1 << 2) | (1 << 4) | (1 << 8) | (1 << 9) | (2 << 12); #[derive(Clone, Copy)] enum PeerTarget<'a> { @@ -194,6 +195,9 @@ fn overlapped_io( fn cancel_pending_read(application_port: &str) -> Result { let application = Port::open(application_port, true)?; + // COM timeouts are device state and can persist across handles. Explicitly disable them so + // this case measures cancellation rather than racing a timeout configured by an earlier run. + set_timeout(application.handle(), 0)?; let event = Event::new()?; let mut overlapped: OVERLAPPED = unsafe { zeroed() }; overlapped.hEvent = event.handle(); @@ -348,16 +352,17 @@ fn serial_configuration( } let winkeyer = matches!(profile.name, "n1mm-winkeyer" | "wktools"); - let command = if winkeyer { - "baud=1200 parity=N data=8 stop=2 xon=on octs=on odsr=off dtr=on rts=hs" - } else { - "baud=9600 parity=N data=8 stop=1 xon=on octs=on odsr=off dtr=on rts=hs" - }; let mut proposed = original; - let wide = wide(command); - if unsafe { BuildCommDCBW(wide.as_ptr(), &mut proposed) } == 0 { - return Err(last_error("BuildCommDCBW")); - } + proposed.BaudRate = if winkeyer { 1_200 } else { 9_600 }; + proposed.ByteSize = 8; + proposed.Parity = NOPARITY; + proposed.StopBits = if winkeyer { TWOSTOPBITS } else { ONESTOPBIT }; + // Binary mode, CTS output flow, enabled DTR, software XON/XOFF in both directions, + // and RTS handshake. Set the DCB directly so this case tests the serial driver rather + // than BuildCommDCB's command-string parser. + proposed._bitfield = (proposed._bitfield & !(DCB_FLOW_CONTROL_MASK | 1)) | DESIRED_DCB_FLAGS; + proposed.XonChar = 0x11; + proposed.XoffChar = 0x13; let result = (|| { if unsafe { SetCommState(application.handle(), &proposed) } == 0 { @@ -391,7 +396,9 @@ fn serial_configuration( ))); } Ok(format!( - "serial format and flow control accepted: {command}" + "serial format and flow control accepted: baud={} parity=N data=8 stop={} xon=on octs=on odsr=off dtr=on rts=hs", + proposed.BaudRate, + if winkeyer { 2 } else { 1 } )) })(); diff --git a/crates/cathub/Cargo.toml b/crates/cathub/Cargo.toml index eeb88ed..9826adc 100644 --- a/crates/cathub/Cargo.toml +++ b/crates/cathub/Cargo.toml @@ -41,6 +41,7 @@ windows-sys = { version = "0.59", features = [ "Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", + "Win32_System_IO", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_Shell", diff --git a/crates/cathub/src/managed_virtual_serial.rs b/crates/cathub/src/managed_virtual_serial.rs index c9d1273..07097a2 100644 --- a/crates/cathub/src/managed_virtual_serial.rs +++ b/crates/cathub/src/managed_virtual_serial.rs @@ -105,9 +105,10 @@ fn spawn_bridge(mut worker: platform::Worker) -> DuplexStream { #[allow(unsafe_code)] mod platform { use std::fs::{File, OpenOptions}; - use std::io::{self, Read, Write}; - use std::mem::{offset_of, size_of}; + use std::io; + use std::mem::{offset_of, size_of, zeroed}; use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; use std::ptr::{null, null_mut}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -123,9 +124,14 @@ mod platform { SP_DEVICE_INTERFACE_DATA, SP_DEVICE_INTERFACE_DETAIL_DATA_W, }; use windows_sys::Win32::Foundation::{ - GetLastError, ERROR_INSUFFICIENT_BUFFER, ERROR_NO_MORE_ITEMS, INVALID_HANDLE_VALUE, + CloseHandle, GetLastError, ERROR_INSUFFICIENT_BUFFER, ERROR_IO_PENDING, + ERROR_NO_MORE_ITEMS, HANDLE, INVALID_HANDLE_VALUE, }; - use windows_sys::Win32::Storage::FileSystem::{SECURITY_IMPERSONATION, SECURITY_SQOS_PRESENT}; + use windows_sys::Win32::Storage::FileSystem::{ + ReadFile, WriteFile, FILE_FLAG_OVERLAPPED, SECURITY_IMPERSONATION, SECURITY_SQOS_PRESENT, + }; + use windows_sys::Win32::System::Threading::CreateEventW; + use windows_sys::Win32::System::IO::{GetOverlappedResult, OVERLAPPED}; const PRIVATE_INTERFACE: GUID = GUID { data1: 0x0084_BDDE, @@ -177,7 +183,7 @@ mod platform { let mut file = OpenOptions::new() .read(true) .write(true) - .custom_flags(SECURITY_SQOS_PRESENT | SECURITY_IMPERSONATION) + .custom_flags(SECURITY_SQOS_PRESENT | SECURITY_IMPERSONATION | FILE_FLAG_OVERLAPPED) .open(path)?; let mut protocol = DaemonProtocol::new(); @@ -276,14 +282,14 @@ mod platform { #[allow(clippy::needless_pass_by_value)] fn reader_loop( - mut file: File, + file: File, protocol: Arc>, stopping: Arc, events: mpsc::Sender, ) { let mut buffer = vec![0_u8; 16 * 1024]; loop { - let count = match file.read(&mut buffer) { + let count = match read_overlapped(&file, &mut buffer) { Ok(0) => { let _ = events.blocking_send(Event::Closed); return; @@ -392,7 +398,7 @@ mod platform { let mut all_events = Vec::new(); let mut buffer = vec![0_u8; 16 * 1024]; loop { - let count = file.read(&mut buffer)?; + let count = read_overlapped(file, &mut buffer)?; if count == 0 { return Err(io::Error::new( io::ErrorKind::UnexpectedEof, @@ -411,13 +417,115 @@ mod platform { } fn write_frame(file: &mut File, frame: &[u8]) -> io::Result<()> { - file.write_all(frame) + let mut written = 0; + while written < frame.len() { + let count = write_overlapped(file, frame.get(written..).unwrap_or_default())?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::WriteZero, + "UMDF private channel accepted zero bytes", + )); + } + written += count; + } + Ok(()) } fn write_optional_frame(file: &mut File, frame: Option>) -> io::Result<()> { frame.map_or(Ok(()), |frame| write_frame(file, &frame)) } + fn read_overlapped(file: &File, buffer: &mut [u8]) -> io::Result { + let length = u32::try_from(buffer.len()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "read buffer is too large"))?; + let event = EventHandle::new()?; + // SAFETY: OVERLAPPED is a plain Windows I/O descriptor; zero is its required baseline. + let mut overlapped: OVERLAPPED = unsafe { zeroed() }; + overlapped.hEvent = event.0; + let mut transferred = 0_u32; + // SAFETY: The file was opened for overlapped I/O and all buffers live through completion. + let started = unsafe { + ReadFile( + file.as_raw_handle(), + buffer.as_mut_ptr().cast(), + length, + &raw mut transferred, + &raw mut overlapped, + ) + }; + finish_overlapped(file, &overlapped, started, &mut transferred)?; + usize::try_from(transferred) + .map_err(|_| io::Error::other("overlapped read length does not fit usize")) + } + + fn write_overlapped(file: &File, buffer: &[u8]) -> io::Result { + let length = u32::try_from(buffer.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "write buffer is too large") + })?; + let event = EventHandle::new()?; + // SAFETY: OVERLAPPED is a plain Windows I/O descriptor; zero is its required baseline. + let mut overlapped: OVERLAPPED = unsafe { zeroed() }; + overlapped.hEvent = event.0; + let mut transferred = 0_u32; + // SAFETY: The file was opened for overlapped I/O and all buffers live through completion. + let started = unsafe { + WriteFile( + file.as_raw_handle(), + buffer.as_ptr().cast(), + length, + &raw mut transferred, + &raw mut overlapped, + ) + }; + finish_overlapped(file, &overlapped, started, &mut transferred)?; + usize::try_from(transferred) + .map_err(|_| io::Error::other("overlapped write length does not fit usize")) + } + + fn finish_overlapped( + file: &File, + overlapped: &OVERLAPPED, + started: i32, + transferred: &mut u32, + ) -> io::Result<()> { + if started == 0 { + // SAFETY: GetLastError immediately follows the failed Windows I/O call. + let error = unsafe { GetLastError() }; + if error != ERROR_IO_PENDING { + return Err(io::Error::from_raw_os_error( + i32::try_from(error).unwrap_or(i32::MAX), + )); + } + // SAFETY: The file and OVERLAPPED descriptor remain live until completion. + if unsafe { GetOverlappedResult(file.as_raw_handle(), overlapped, transferred, 1) } == 0 + { + return Err(io::Error::last_os_error()); + } + } + Ok(()) + } + + struct EventHandle(HANDLE); + + impl EventHandle { + fn new() -> io::Result { + // SAFETY: Null security/name pointers request a private manual-reset event. + let handle = unsafe { CreateEventW(null(), 1, 0, null()) }; + if handle.is_null() { + Err(io::Error::last_os_error()) + } else { + Ok(Self(handle)) + } + } + } + + impl Drop for EventHandle { + fn drop(&mut self) { + // SAFETY: This wrapper exclusively owns the event handle. + let _ = unsafe { CloseHandle(self.0) }; + } + } + fn protocol_error(error: impl std::fmt::Display) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, error.to_string()) } diff --git a/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx b/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx index 60ec08f..37e5d5b 100644 --- a/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx +++ b/drivers/cathub-virtual-serial-umdf/cathub_virtual_serial_umdf.inx @@ -52,6 +52,7 @@ UmdfHostProcessSharing=ProcessSharingDisabled UmdfKernelModeClientPolicy=AllowKernelModeClients UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects UmdfFsContextUsePolicy=CannotUseFsContexts +UmdfImpersonationLevel=Impersonation [CatHubHdsdr_Install.NT] CopyFiles=DriverCopy @@ -74,6 +75,7 @@ UmdfHostProcessSharing=ProcessSharingDisabled UmdfKernelModeClientPolicy=AllowKernelModeClients UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects UmdfFsContextUsePolicy=CannotUseFsContexts +UmdfImpersonationLevel=Impersonation [CatHubN1mmCat_Install.NT] CopyFiles=DriverCopy @@ -96,6 +98,7 @@ UmdfHostProcessSharing=ProcessSharingDisabled UmdfKernelModeClientPolicy=AllowKernelModeClients UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects UmdfFsContextUsePolicy=CannotUseFsContexts +UmdfImpersonationLevel=Impersonation [CatHubArcp_Install.NT] CopyFiles=DriverCopy @@ -118,6 +121,7 @@ UmdfHostProcessSharing=ProcessSharingDisabled UmdfKernelModeClientPolicy=AllowKernelModeClients UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects UmdfFsContextUsePolicy=CannotUseFsContexts +UmdfImpersonationLevel=Impersonation [CatHubN1mmWinkeyer_Install.NT] CopyFiles=DriverCopy @@ -140,6 +144,7 @@ UmdfHostProcessSharing=ProcessSharingDisabled UmdfKernelModeClientPolicy=AllowKernelModeClients UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects UmdfFsContextUsePolicy=CannotUseFsContexts +UmdfImpersonationLevel=Impersonation [CatHubWktools_Install.NT] CopyFiles=DriverCopy @@ -162,11 +167,11 @@ UmdfHostProcessSharing=ProcessSharingDisabled UmdfKernelModeClientPolicy=AllowKernelModeClients UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects UmdfFsContextUsePolicy=CannotUseFsContexts +UmdfImpersonationLevel=Impersonation [CatHubUMDFDevice_WdfInstall] UmdfLibraryVersion=$UMDFVERSION$ ServiceBinary=%13%\cathub_virtual_serial_umdf.dll -UmdfImpersonationLevel=Impersonation [SetDeviceType_AddReg] HKR,,DeviceType,0x10001,0x0000001b diff --git a/drivers/cathub-virtual-serial-umdf/src/interop.rs b/drivers/cathub-virtual-serial-umdf/src/interop.rs index 2b739ce..025c1fa 100644 --- a/drivers/cathub-virtual-serial-umdf/src/interop.rs +++ b/drivers/cathub-virtual-serial-umdf/src/interop.rs @@ -1,7 +1,7 @@ //! Auditable Windows/WDF FFI boundary. use std::{ - collections::VecDeque, + collections::{HashSet, VecDeque}, ffi::c_void, mem::size_of, panic::{AssertUnwindSafe, catch_unwind}, @@ -46,6 +46,7 @@ const STATUS_DEVICE_NOT_CONNECTED: NTSTATUS = -1_073_741_667; const STATUS_CANCELLED: NTSTATUS = -1_073_741_536; const STATUS_BUFFER_OVERFLOW: NTSTATUS = -2_147_483_643; const TRUE: BOOLEAN = 1; +const READ_TIMEOUT_TIMER_DUE_TIME_100NS: i64 = -100_000; const DAEMON_REFERENCE: [u16; 7] = [100, 97, 101, 109, 111, 110, 0]; const PORT_NAME_VALUE: [u16; 9] = [80, 111, 114, 116, 78, 97, 109, 101, 0]; @@ -102,7 +103,7 @@ struct DeviceState { application_reads: AtomicPtr, daemon_reads: AtomicPtr, wait_requests: AtomicPtr, - pending_application_reads: Mutex>, + pending_application_reads: Mutex, daemon_owner_sid: Box<[u8]>, } @@ -112,6 +113,33 @@ struct PendingRead { deadline: Option, } +#[derive(Debug, Default)] +struct PendingApplicationReads { + queued: VecDeque, + canceled_before_tracking: HashSet, +} + +impl PendingApplicationReads { + fn track(&mut self, pending: PendingRead) { + if !self.canceled_before_tracking.remove(&pending.request) { + self.queued.push_back(pending); + } + } + + fn cancel(&mut self, request: usize) { + let original_len = self.queued.len(); + self.queued.retain(|pending| pending.request != request); + if self.queued.len() == original_len { + self.canceled_before_tracking.insert(request); + } + } + + fn clear(&mut self) { + self.queued.clear(); + self.canceled_before_tracking.clear(); + } +} + impl DeviceState { #[cfg(test)] fn new() -> Self { @@ -128,7 +156,7 @@ impl DeviceState { application_reads: AtomicPtr::new(ptr::null_mut()), daemon_reads: AtomicPtr::new(ptr::null_mut()), wait_requests: AtomicPtr::new(ptr::null_mut()), - pending_application_reads: Mutex::new(VecDeque::new()), + pending_application_reads: Mutex::new(PendingApplicationReads::default()), daemon_owner_sid, } } @@ -162,7 +190,7 @@ impl DeviceState { self.wait_requests.load(Ordering::Acquire) } - fn pending_application_reads(&self) -> MutexGuard<'_, VecDeque> { + fn pending_application_reads(&self) -> MutexGuard<'_, PendingApplicationReads> { self.pending_application_reads .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -418,12 +446,9 @@ unsafe fn record_startup_diagnostic(device: WDFDEVICE, stage: u32, status: NTSTA } unsafe fn configure_read_timeout_timer(device: WDFDEVICE) -> NTSTATUS { - const TIMER_PERIOD_MS: u32 = 10; - const FIRST_DUE_TIME_100NS: i64 = -100_000; let mut config = WDF_TIMER_CONFIG { Size: struct_size::(), EvtTimerFunc: Some(evt_read_timeout_timer), - Period: TIMER_PERIOD_MS, ..WDF_TIMER_CONFIG::default() }; let mut attributes = WDF_OBJECT_ATTRIBUTES { @@ -447,8 +472,9 @@ unsafe fn configure_read_timeout_timer(device: WDFDEVICE) -> NTSTATUS { return status; } // SAFETY: The timer was created successfully and accepts a relative 100ns due time. - let _was_queued = - unsafe { call_unsafe_wdf_function_binding!(WdfTimerStart, timer, FIRST_DUE_TIME_100NS) }; + let _was_queued = unsafe { + call_unsafe_wdf_function_binding!(WdfTimerStart, timer, READ_TIMEOUT_TIMER_DUE_TIME_100NS,) + }; STATUS_SUCCESS } @@ -482,7 +508,10 @@ unsafe fn configure_queues(device: WDFDEVICE, state: &DeviceState) -> NTSTATUS { let mut default_queue: WDFQUEUE = ptr::null_mut(); let mut config = WDF_IO_QUEUE_CONFIG { Size: struct_size::(), - DispatchType: _WDF_IO_QUEUE_DISPATCH_TYPE::WdfIoQueueDispatchSequential, + // Reads can remain pending while the daemon supplies their data. Parallel dispatch is + // required so that a pending application read does not block the daemon write that + // satisfies it; mutable transport state is protected by the DeviceState locks. + DispatchType: _WDF_IO_QUEUE_DISPATCH_TYPE::WdfIoQueueDispatchParallel, PowerManaged: _WDF_TRI_STATE::WdfUseDefault, AllowZeroLengthRequests: TRUE, DefaultQueue: TRUE, @@ -492,6 +521,10 @@ unsafe fn configure_queues(device: WDFDEVICE, state: &DeviceState) -> NTSTATUS { EvtIoDeviceControl: Some(evt_io_device_control), ..WDF_IO_QUEUE_CONFIG::default() }; + // WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE sets this union member to ULONG_MAX for a + // parallel queue. The generated Rust Default implementation zeroes it, which means WDF + // presents no requests at all. + config.Settings.Parallel.NumberOfPresentedRequests = u32::MAX; // SAFETY: WDF copies the config; null attributes are allowed and output storage is valid. unsafe { call_unsafe_wdf_function_binding!( @@ -746,6 +779,8 @@ unsafe fn register_legacy_serial_map(device: WDFDEVICE, port_name: &[u16]) -> NT let mut memory_attributes = WDF_OBJECT_ATTRIBUTES { Size: struct_size::(), ParentObject: device.cast(), + ExecutionLevel: _WDF_EXECUTION_LEVEL::WdfExecutionLevelInheritFromParent, + SynchronizationScope: _WDF_SYNCHRONIZATION_SCOPE::WdfSynchronizationScopeInheritFromParent, ..WDF_OBJECT_ATTRIBUTES::default() }; let mut memory: WDFMEMORY = ptr::null_mut(); @@ -1020,9 +1055,7 @@ unsafe extern "C" fn evt_io_canceled_on_queue(queue: WDFQUEUE, request: WDFREQUE ffi_void(|| { // SAFETY: WDF keeps the queue's parent device alive for this callback. if let Some(state) = unsafe { device_state_from_queue(queue) } { - state - .pending_application_reads() - .retain(|pending| pending.request != request.addr()); + state.pending_application_reads().cancel(request.addr()); } // SAFETY: WDF transfers ownership of the canceled request to the callback. unsafe { complete_request(request, STATUS_CANCELLED, 0) }; @@ -1042,6 +1075,16 @@ unsafe extern "C" fn evt_read_timeout_timer(timer: WDFTIMER) { }; // SAFETY: The application read queue remains live while its parent device timer runs. unsafe { service_expired_application_reads(state) }; + // UMDF passive-level timers must be one-shot. Rearm this timer after each scan rather + // than using WDF_TIMER_CONFIG.Period, which WDF rejects with STATUS_NOT_SUPPORTED. + // SAFETY: WDF keeps the timer live for the duration of its callback. + let _was_queued = unsafe { + call_unsafe_wdf_function_binding!( + WdfTimerStart, + timer, + READ_TIMEOUT_TIMER_DUE_TIME_100NS, + ) + }; }); } @@ -1061,7 +1104,10 @@ unsafe fn handle_read( return RequestDisposition::success(0); } let available = match role { - ChannelRole::Application => state.channel().plane.available_to_read(role), + // An application is allowed to establish a pending or timed read before the daemon + // attaches. Writes still fail closed until the daemon is present, and daemon cleanup + // drains already-pending reads. + ChannelRole::Application => Ok(state.channel().plane.incoming_len(role)), ChannelRole::Daemon => state.channel().plane.available_for_daemon(), }; match available { @@ -1078,18 +1124,16 @@ unsafe fn handle_read( if queue.is_null() { return RequestDisposition::error(STATUS_UNSUCCESSFUL); } - let mut pending = - (role == ChannelRole::Application).then(|| state.pending_application_reads()); // SAFETY: The request is framework-owned and target is a valid manual queue. let status = unsafe { call_unsafe_wdf_function_binding!(WdfRequestForwardToIoQueue, request, queue) }; if nt_success(status) { - if let Some(pending) = pending.as_mut() { + if role == ChannelRole::Application { let deadline = timeout_ms.and_then(|milliseconds| { Instant::now().checked_add(Duration::from_millis(milliseconds)) }); - pending.push_back(PendingRead { + state.pending_application_reads().track(PendingRead { request: request.addr(), deadline, }); @@ -1664,7 +1708,9 @@ unsafe fn service_pending_reads(state: &DeviceState, role: ChannelRole) { break; } if let Some(pending) = pending_application.as_mut() { - pending.retain(|entry| entry.request != request.addr()); + pending + .queued + .retain(|entry| entry.request != request.addr()); } // SAFETY: Retrieval transfers the queued request to this driver. let disposition = unsafe { read_available(state, request, role, usize::MAX) }; @@ -1681,13 +1727,14 @@ unsafe fn service_expired_application_reads(state: &DeviceState) { let mut pending = state.pending_application_reads(); loop { let expired = pending + .queued .front() .and_then(|entry| entry.deadline) .is_some_and(|deadline| deadline <= Instant::now()); if !expired { break; } - let expected = pending.front().map(|entry| entry.request); + let expected = pending.queued.front().map(|entry| entry.request); let mut request: WDFREQUEST = ptr::null_mut(); // SAFETY: Queue is a valid manual queue and request is valid output storage. let status = unsafe { @@ -1701,9 +1748,11 @@ unsafe fn service_expired_application_reads(state: &DeviceState) { pending.clear(); break; } - pending.pop_front(); + pending.queued.pop_front(); if expected != Some(request.addr()) { - pending.retain(|entry| entry.request != request.addr()); + pending + .queued + .retain(|entry| entry.request != request.addr()); } drop(pending); // SAFETY: Retrieval transfers ownership of this expired request to the driver. @@ -1752,6 +1801,9 @@ unsafe fn role_from_file(file: WDFFILEOBJECT) -> Option { } // SAFETY: WDF returns a valid counted string for the file object's lifetime. let name = unsafe { &*name }; + if name.Length == 0 { + return Some(ChannelRole::Application); + } if name.Buffer.is_null() || name.Length % 2 != 0 { return None; } @@ -2053,4 +2105,32 @@ mod tests { assert!(first.queue_for(ChannelRole::Application).is_null()); assert!(second.queue_for(ChannelRole::Daemon).is_null()); } + + #[test] + fn pending_read_cancellation_removes_an_already_tracked_request() { + let mut reads = PendingApplicationReads::default(); + reads.track(PendingRead { + request: 17, + deadline: None, + }); + + reads.cancel(17); + + assert!(reads.queued.is_empty()); + assert!(reads.canceled_before_tracking.is_empty()); + } + + #[test] + fn pending_read_cancellation_race_is_consumed_when_tracking_catches_up() { + let mut reads = PendingApplicationReads::default(); + reads.cancel(23); + + reads.track(PendingRead { + request: 23, + deadline: None, + }); + + assert!(reads.queued.is_empty()); + assert!(reads.canceled_before_tracking.is_empty()); + } } diff --git a/drivers/cathub-virtual-serial-umdf/src/serial.rs b/drivers/cathub-virtual-serial-umdf/src/serial.rs index 0beb4b0..067899d 100644 --- a/drivers/cathub-virtual-serial-umdf/src/serial.rs +++ b/drivers/cathub-virtual-serial-umdf/src/serial.rs @@ -310,9 +310,7 @@ impl SerialState { } pub const fn set_timeouts(&mut self, value: SerialTimeouts) -> Result<(), SerialStateError> { - if value.read_interval_timeout == u32::MAX - && value.read_total_timeout_multiplier == u32::MAX - && value.read_total_timeout_constant == u32::MAX + if value.read_interval_timeout == u32::MAX && value.read_total_timeout_constant == u32::MAX { return Err(SerialStateError::Timeouts); } @@ -333,6 +331,15 @@ impl SerialState { { return Some(0); } + // Windows and .NET SerialPort use this sentinel combination for a read that returns as + // soon as one byte arrives, or after the constant when the input buffer remains empty. + if timeouts.read_interval_timeout == u32::MAX + && timeouts.read_total_timeout_multiplier == u32::MAX + && timeouts.read_total_timeout_constant > 0 + && timeouts.read_total_timeout_constant < u32::MAX + { + return Some(u64::from(timeouts.read_total_timeout_constant)); + } let multiplier = u64::from(timeouts.read_total_timeout_multiplier); let requested = u64::try_from(requested_bytes).unwrap_or(u64::MAX); let total = multiplier @@ -595,7 +602,7 @@ mod tests { } #[test] - fn rejects_windows_unsupported_all_maximum_read_timeouts() { + fn rejects_windows_unsupported_maximum_interval_and_constant() { let mut state = SerialState::default(); assert_eq!( state.set_timeouts(SerialTimeouts { @@ -606,6 +613,15 @@ mod tests { }), Err(SerialStateError::Timeouts) ); + assert_eq!( + state.set_timeouts(SerialTimeouts { + read_interval_timeout: u32::MAX, + read_total_timeout_multiplier: 0, + read_total_timeout_constant: u32::MAX, + ..SerialTimeouts::default() + }), + Err(SerialStateError::Timeouts) + ); } #[test] @@ -628,6 +644,16 @@ mod tests { }) .expect("immediate timeouts"); assert_eq!(state.empty_read_timeout_ms(10), Some(0)); + + state + .set_timeouts(SerialTimeouts { + read_interval_timeout: u32::MAX, + read_total_timeout_multiplier: u32::MAX, + read_total_timeout_constant: 100, + ..SerialTimeouts::default() + }) + .expect("first-byte timeout"); + assert_eq!(state.empty_read_timeout_ms(10), Some(100)); } #[test] diff --git a/scripts/Test-UmdfEndToEnd.ps1 b/scripts/Test-UmdfEndToEnd.ps1 index 9b31e73..f335e3f 100644 --- a/scripts/Test-UmdfEndToEnd.ps1 +++ b/scripts/Test-UmdfEndToEnd.ps1 @@ -43,6 +43,38 @@ function Invoke-Captured { } } +function Write-JsonObject { + param( + [Parameter(Mandatory)][System.Collections.IDictionary]$InputObject, + [Parameter(Mandatory)][string]$Path + ) + + $encoding = [System.Text.UTF8Encoding]::new($false) + $writer = [System.IO.StreamWriter]::new($Path, $false, $encoding) + try { + $writer.Write('{') + $first = $true + foreach ($entry in $InputObject.GetEnumerator()) { + if (-not $first) { + $writer.Write(',') + } + $first = $false + $writer.Write((ConvertTo-Json -InputObject ([string]$entry.Key) -Compress)) + $writer.Write(':') + $writer.Flush() + if ($null -eq $entry.Value) { + $writer.Write('null') + } else { + $writer.Write((ConvertTo-Json -InputObject $entry.Value -Depth 10 -Compress)) + } + } + $writer.Write('}') + } + finally { + $writer.Dispose() + } +} + function Get-SignatureEvidence { param([Parameter(Mandatory)][string]$Path) @@ -157,16 +189,24 @@ function Get-PnpEvidence { $property = Get-PnpDeviceProperty -InstanceId $InstanceId -KeyName $key ` -ErrorAction SilentlyContinue if ($property) { - $properties[$key] = $property.Data + $properties[$key] = if ($null -eq $property.Data) { + $null + } elseif ($property.Data -is [Array]) { + @($property.Data | ForEach-Object { [string]$_ }) + } elseif ($property.Data -is [ValueType]) { + $property.Data + } else { + [string]$property.Data + } } } return [ordered]@{ - instance_id = $device.InstanceId - class = $device.Class - friendly_name = $device.FriendlyName - status = $device.Status - problem = $device.Problem + instance_id = [string]$device.InstanceId + class = [string]$device.Class + friendly_name = [string]$device.FriendlyName + status = [string]$device.Status + problem = [string]$device.Problem properties = $properties } } @@ -178,11 +218,17 @@ function Get-EventEvidence { ) try { - return @( - Get-WinEvent -FilterHashtable @{ LogName = $LogName; StartTime = $StartTime } ` - -ErrorAction Stop | - Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message - ) + return @(foreach ($event in Get-WinEvent ` + -FilterHashtable @{ LogName = $LogName; StartTime = $StartTime } ` + -MaxEvents 200 -ErrorAction Stop) { + [ordered]@{ + time_created_utc = $event.TimeCreated.ToUniversalTime().ToString('o') + id = [int]$event.Id + level = [string]$event.LevelDisplayName + provider = [string]$event.ProviderName + message = [string]$event.Message + } + }) } catch { return @([ordered]@{ collection_error = $_.Exception.Message }) @@ -623,7 +669,10 @@ try { response = $restartId } - Invoke-Checked pnputil @('/restart-device', $device.InstanceId) + $results.device_restart = Invoke-Captured pnputil @('/restart-device', $device.InstanceId) + if ($results.device_restart.exit_code -notin @(0, 3010)) { + throw "Restarting the CatHub device failed: $($results.device_restart.output)" + } $serial.ReadTimeout = 1000 $deviceFailureTimer = [System.Diagnostics.Stopwatch]::StartNew() $deviceFailure = $null @@ -808,7 +857,7 @@ finally { } $results.completed_at_utc = [DateTime]::UtcNow.ToString('o') $results.duration_ms = [long]([DateTime]::UtcNow - $testStart).TotalMilliseconds - $results | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $ResultsPath -Encoding utf8 + Write-JsonObject -InputObject $results -Path $ResultsPath } if ($failure) { From f641c2b028871b5ce90dc9bfcd912da393c14f76 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Sat, 22 Aug 2026 16:25:19 -0700 Subject: [PATCH 38/39] Harden UMDF end-to-end acceptance harness --- scripts/Test-UmdfEndToEnd.ps1 | 61 +++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/scripts/Test-UmdfEndToEnd.ps1 b/scripts/Test-UmdfEndToEnd.ps1 index f335e3f..2972e90 100644 --- a/scripts/Test-UmdfEndToEnd.ps1 +++ b/scripts/Test-UmdfEndToEnd.ps1 @@ -669,30 +669,28 @@ try { response = $restartId } - $results.device_restart = Invoke-Captured pnputil @('/restart-device', $device.InstanceId) - if ($results.device_restart.exit_code -notin @(0, 3010)) { - throw "Restarting the CatHub device failed: $($results.device_restart.output)" - } - $serial.ReadTimeout = 1000 - $deviceFailureTimer = [System.Diagnostics.Stopwatch]::StartNew() - $deviceFailure = $null - try { - $serial.Write('ID;') - $null = $serial.ReadTo(';') - } - catch { - $deviceFailure = $_.Exception.Message - } - $deviceFailureTimer.Stop() - if (-not $deviceFailure) { - throw 'The pre-restart COM handle unexpectedly remained usable after device restart.' - } - if ($deviceFailureTimer.ElapsedMilliseconds -gt 3000) { - throw "Device-restart I/O failure took $($deviceFailureTimer.ElapsedMilliseconds) ms." - } $serial.Close() $serial.Dispose() $serial = $null + Stop-Process -Id $process.Id -Force + $process.WaitForExit() + $process = $null + + $deviceCycleTimer = [System.Diagnostics.Stopwatch]::StartNew() + $disable = Invoke-Captured pnputil @('/disable-device', $device.InstanceId) + if ($disable.exit_code -ne 0) { + throw "Disabling the CatHub device failed: $($disable.output)" + } + $enable = Invoke-Captured pnputil @('/enable-device', $device.InstanceId) + if ($enable.exit_code -ne 0) { + throw "Re-enabling the CatHub device failed: $($enable.output)" + } + $results.device_restart = [ordered]@{ + method = 'pnputil disable/enable' + disable = $disable + enable = $enable + } + $deviceCycleTimer.Stop() $restartedDevice = Find-CatHubDevice -Executable $CatHubExe -StableId 'cathub-default' if ($restartedDevice.InstanceId -ne $device.InstanceId) { @@ -700,6 +698,16 @@ try { } $results.pnp_after_restart = Get-PnpEvidence -InstanceId $restartedDevice.InstanceId + $process = Start-Process -FilePath $CatHubExe ` + -ArgumentList @('--config', $configPath) ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath ` + -PassThru + Start-Sleep -Seconds 2 + if ($process.HasExited) { + throw "CatHub exited after the device cycle with code $($process.ExitCode)." + } + $reconnectDeadline = [DateTime]::UtcNow.AddSeconds(20) $deviceRestartId = $null $lastReconnectError = $null @@ -734,8 +742,7 @@ try { $results.cases += [ordered]@{ name = 'umdf_device_restart_reconnect' passed = $true - failure_elapsed_ms = $deviceFailureTimer.ElapsedMilliseconds - failure_error = $deviceFailure + cycle_elapsed_ms = $deviceCycleTimer.ElapsedMilliseconds response = $deviceRestartId } $results.passed = $true @@ -836,16 +843,16 @@ finally { } $results.cathub_stdout = if (Test-Path -LiteralPath $stdoutPath) { - Get-Content -LiteralPath $stdoutPath -Raw + [string](Get-Content -LiteralPath $stdoutPath -Raw) } else { '' } $results.cathub_stderr = if (Test-Path -LiteralPath $stderrPath) { - Get-Content -LiteralPath $stderrPath -Raw + [string](Get-Content -LiteralPath $stderrPath -Raw) } else { '' } $results.test_peer_stdout = if (Test-Path -LiteralPath $peerStdoutPath) { - Get-Content -LiteralPath $peerStdoutPath -Raw + [string](Get-Content -LiteralPath $peerStdoutPath -Raw) } else { '' } $results.test_peer_stderr = if (Test-Path -LiteralPath $peerStderrPath) { - Get-Content -LiteralPath $peerStderrPath -Raw + [string](Get-Content -LiteralPath $peerStderrPath -Raw) } else { '' } $results.events = [ordered]@{ system = Get-EventEvidence -LogName 'System' -StartTime $testStart From ab669c36da0804514e50c4c58a5ec92960842e71 Mon Sep 17 00:00:00 2001 From: Randy Treit Date: Sat, 22 Aug 2026 17:15:53 -0700 Subject: [PATCH 39/39] Document UMDF installed-driver acceptance --- docs/design/virtual-serial-umdf-poc.md | 46 +++++++++++++------- docs/integration/windows-virtual-serial.md | 30 ++++++++++--- docs/testing/serial-client-inventory.md | 14 +++--- drivers/cathub-virtual-serial-umdf/README.md | 27 ++++++++++-- scripts/Test-UmdfEndToEnd.ps1 | 4 +- 5 files changed, 89 insertions(+), 32 deletions(-) diff --git a/docs/design/virtual-serial-umdf-poc.md b/docs/design/virtual-serial-umdf-poc.md index acc331c..fe57b12 100644 --- a/docs/design/virtual-serial-umdf-poc.md +++ b/docs/design/virtual-serial-umdf-poc.md @@ -40,6 +40,7 @@ Available on the development station: - Visual Studio 2026 Build Tools and Visual Studio 2022 - Windows SDK directories through 10.0.26100.0 - User-local Microsoft WDK NuGet package 10.0.28000.2526 and SDK dependency 10.0.28000.1721 +- Pinned `windows-drivers-rs` checkout and Microsoft VirtualSerial2 source cache - `cargo-make` 0.37.24 and `rust-script` 0.36.0 - N1MM Logger+ and the existing com0com pairs, which remain untouched for comparison @@ -50,9 +51,10 @@ a short-lived development signature in the DLL before regenerating and signing t With explicit operator authorization, the development package was provisioned locally as COM91 without changing or removing any com0com device. Windows staged the package and verified both -signatures, but normal boot policy rejected the self-signed image with -`ERROR_INVALID_IMAGE_HASH`. Installed functional validation therefore awaits an explicitly -authorized Test Signing reboot or a publicly/Microsoft-signed package. +signatures. Normal boot policy correctly rejected the self-signed image with +`ERROR_INVALID_IMAGE_HASH`; after the operator explicitly enabled Windows Test Signing and +rebooted, the exact `f641c2b` package loaded successfully with Memory Integrity running and +integrity checks enabled. The full installed-driver acceptance described below then passed. ## Microsoft sample inventory @@ -66,7 +68,7 @@ The source remains upstream; CatHub does not copy the C implementation. |---|---|---| | Driver | `DriverEntry`, `EVT_WDF_DRIVER_DEVICE_ADD` | Pure Rust entry and device creation implemented | | Device | Device context and cleanup callback | Typed per-device context and destroy cleanup implemented | -| Default queue | Parallel read, write, and device-control callbacks | Sequential read, write, and serial-control queue implemented | +| Default queue | Parallel read, write, and device-control callbacks | Parallel read, write, and serial-control queue implemented | | Pending reads | Manual queue | Two manual queues with cancellation and disconnect draining implemented | | Pending event wait | Separate manual queue | One-outstanding-wait manual queue implemented | | Cleanup | Device cleanup releases COM mapping | Application/daemon detach drains requests and clears bounded buffers | @@ -99,7 +101,7 @@ VirtualSerial2 uses the Ports class, `FILE_DEVICE_SERIAL_PORT`, `GUID_DEVINTERFA name stored in the device map, and a symbolic link. Its Windows 11 INF includes `WUDFRD.inf` and configures a UMDF service hosted through the reflector. -CatHub will add a separate ACL-restricted private device interface and a stable endpoint ID. +CatHub adds a separate ACL-restricted private device interface and a stable endpoint ID. Those are CatHub requirements, not behaviors supplied by VirtualSerial2. ## Unsafe and FFI inventory @@ -124,14 +126,26 @@ byte buffers, exclusive-handle state, application session sequence, and pending- Each device owns those values through typed WDF context; file and queue callbacks resolve their parent device before accessing the state. -## Remaining installed-validation gates - -1. Load the self-signed development image under explicitly authorized Windows Test Signing policy, - or obtain a public/Microsoft signature suitable for normal policy. -2. Confirm the device starts, both interfaces enumerate, and the stable COM mapping survives a - clean device restart. -3. Run every native serial conformance profile through the private CatHub adapter. -4. Run the `n1mm-radio` sequence in `docs/testing/n1mm-radio-poc.md` and repeat the applicable - flows for the other four legacy clients. -5. Exercise daemon failure, UMDF-host restart, repair, upgrade, rollback, removal, sleep, and resume - while preserving fail-safe transmit behavior. +## Development acceptance and remaining production gates + +The exact package for `f641c2b028871b5ce90dc9bfcd912da393c14f76` passed the elevated local +end-to-end harness on 2026-08-22. The retained device was healthy as COM91 after reboot and after a +PnP disable/enable cycle. The run verified both interfaces, stable identity, native synchronous +and overlapped I/O, cancellation, timeouts, purge, `WaitCommEvent`, DCB and modem controls, queue +status, bounded-buffer rejection and recovery, exclusive open/reopen, .NET `SerialPort`, CatHub +TS-590 traffic, daemon failure/restart, and reconnect. Every automated compatibility profile +passed 11 of 11 checks, for 55 of 55 total. + +That evidence used Windows Test Signing and the development certificate with Secure Boot disabled +under an explicit exception. Issue #6 still requires these production gates: + +1. Obtain an approved public or Microsoft signing path and run the clean Windows 11 acceptance + flow with Secure Boot and Memory Integrity enabled, Test Signing disabled, and no publisher + certificate preinstalled. +2. Capture and document runs from the five actual supported clients: HDSDR/OmniRig, N1MM CAT, + ARCP-590, N1MM WinKeyer, and WKTools. The automated profiles validate their expected API shapes + but are not substitutes for those application runs. +3. Exercise sleep/resume, sustained high-rate and cancellation-race stress, and fail-safe PTT and + keying behavior through real endpoint lifecycle changes. +4. Implement and validate installer upgrade, repair, rollback, and uninstall behavior, including + subsequent standard-user operation. diff --git a/docs/integration/windows-virtual-serial.md b/docs/integration/windows-virtual-serial.md index a017e84..9cd4478 100644 --- a/docs/integration/windows-virtual-serial.md +++ b/docs/integration/windows-virtual-serial.md @@ -96,9 +96,9 @@ research and required acceptance record. The self-signed development image requires [Windows Test Signing mode](https://learn.microsoft.com/en-us/windows-hardware/drivers/install/the-testsigning-boot-configuration-option). -On a dedicated development target, disable Secure Boot, enable Test Signing, and reboot. Leave -Memory Integrity enabled; the driver DLL remains signed and the harness rejects disabled integrity -checks. Then run the harness from an elevated PowerShell session: +A dedicated development target is preferred. Disable Secure Boot, enable Test Signing, and reboot. +Leave Memory Integrity enabled; the driver DLL remains signed and the harness rejects disabled +integrity checks. Then run the harness from an elevated PowerShell session: ```powershell bcdedit.exe -set TESTSIGNING ON @@ -109,6 +109,19 @@ cd C:\CatHubUmdfTest -AllowSecureBootDisabled ``` +The harness rejects a non-Hyper-V machine by default. When the operator explicitly authorizes a +local development installation, also pass `-AllowLocalMachine`. Pass `-KeepInstalled` only when the +working driver and its current development trust certificate must remain available after the test: + +```powershell +.\scripts\Test-UmdfEndToEnd.ps1 ` + -IUnderstandThisInstallsATestDriver ` + -AllowLocalMachine ` + -AllowSecureBootDisabled ` + -KeepInstalled ` + -ResultsPath .\target\cathub-umdf-e2e-final.json +``` + The harness fails before installation unless Test Signing and Memory Integrity are running, integrity checks remain enabled, and disabled Secure Boot was explicitly acknowledged. Its JSON evidence records the OS and boot policy, package manifest, catalog and embedded DLL trust before @@ -122,8 +135,15 @@ handle, exclusive-open/reopen behavior, and both CAT 8-N-1 and WinKeyer 8-N-2 li recreating a second COM port. After a successful run it removes the endpoint, staged OEM driver package, and test certificate and verifies the resulting CatHub inventory. Pass `-KeepInstalled` only when -retaining that isolated VM state is necessary for debugging; failed runs retain state so the -original failure can be inspected before reverting the VM checkpoint. +retaining the development state is intentional; failed runs retain state so the original failure +can be inspected before cleanup or reverting a VM checkpoint. + +The exact `f641c2b` development package completed this local authorized flow on 2026-08-22. COM91 +remained healthy after reboot and a PnP disable/enable cycle, all five automated compatibility +profiles passed 11 of 11 checks, and the real CatHub loopback path survived daemon loss, restart, +and reconnect. See the [UMDF proof-of-concept status](../design/virtual-serial-umdf-poc.md) and +[serial client inventory](../testing/serial-client-inventory.md) for the durable evidence summary +and remaining production gates. Normal-policy acceptance is a separate production-signing gate. After CatHub obtains a public or Microsoft driver signature, repeat the clean-system procedure with Secure Boot enabled and Test diff --git a/docs/testing/serial-client-inventory.md b/docs/testing/serial-client-inventory.md index ca989ab..209e37c 100644 --- a/docs/testing/serial-client-inventory.md +++ b/docs/testing/serial-client-inventory.md @@ -19,11 +19,11 @@ Do not change a state to `Verified` without a saved JSON report. | Profile | Client interface | Serial format | Trace state | Driver report state | |---|---|---|---|---| -| `hdsdr-omnirig` | HDSDR through OmniRig, TS-2000 | Client setting | Planned | Planned | -| `n1mm-radio` | N1MM Logger+ radio CAT, TS-590 | Client setting | Planned | Planned | -| `arcp-590` | Kenwood ARCP-590 | Client setting | Planned | Planned | -| `n1mm-winkeyer` | N1MM Logger+ WinKeyer | 1200 8-N-2 | Planned | Planned | -| `wktools` | WKTools maintenance | 1200 8-N-2 | Planned | Planned | +| `hdsdr-omnirig` | HDSDR through OmniRig, TS-2000 | Client setting | Planned | Verified | +| `n1mm-radio` | N1MM Logger+ radio CAT, TS-590 | Client setting | Planned | Verified | +| `arcp-590` | Kenwood ARCP-590 | Client setting | Planned | Verified | +| `n1mm-winkeyer` | N1MM Logger+ WinKeyer | 1200 8-N-2 | Planned | Verified | +| `wktools` | WKTools maintenance | 1200 8-N-2 | Planned | Verified | ## Initial behavior matrix @@ -41,6 +41,8 @@ Do not change a state to `Verified` without a saved JSON report. | Serial configuration | R | R | R | R | R | | DTR, RTS, and break control | O | O | O | O | O | | Queue status | R | R | R | R | R | +| Atomic buffer-saturation rejection and recovery | R | R | R | R | R | +| Exclusive open, close, and reopen | R | R | R | R | R | ## Trace procedure @@ -65,5 +67,5 @@ Add one row for each application trace or driver report. | Date | Profile | Environment | Evidence type | File or URL | Result | Notes | |---|---|---|---|---|---|---| -| Not run | All | Not run | Planned | None | Pending | Phase 1 code and profiles exist. Application runs remain. | | 2026-08-14 | `n1mm-radio` | Windows development station | Readiness check | [N1MM PoC runbook](n1mm-radio-poc.md) | Blocked | N1MM and the station CatHub process held COM21/COM20. The alternate pair was also in use. No trace was captured and no evidence state changed. | +| 2026-08-22 | All five profiles | Windows 11 Pro x64 build 26200, local development target | Installed-driver conformance report | `target/cathub-umdf-e2e-final.json` (local ignored evidence), PR #13 summary | Passed | Exact `f641c2b` package; 11/11 per profile and 55/55 total through COM91 and the private CHVS channel. Test Signing was enabled, Memory Integrity was running, integrity checks were enabled, and Secure Boot was disabled under an explicit development exception. Actual application traces remain planned. | diff --git a/drivers/cathub-virtual-serial-umdf/README.md b/drivers/cathub-virtual-serial-umdf/README.md index bc50e09..8cd68f2 100644 --- a/drivers/cathub-virtual-serial-umdf/README.md +++ b/drivers/cathub-virtual-serial-umdf/README.md @@ -9,7 +9,11 @@ uses the COM number assigned by the Windows Ports class installer, and creates t global `COMx` symbolic link and `HARDWARE\DEVICEMAP\SERIALCOMM` entry used by legacy enumerators. It also registers the reference-named `daemon` instance of the private CatHub interface `{0084BDDE-9F40-4A6A-AF84-0F4E46B70901}`. -Do not install it on the working station. + +This remains a development driver, not an operator release. Prefer a dedicated test target. +Installation on another machine requires explicit operator authorization because it imports a test +certificate, stages a PnP package, and changes boot-policy requirements. Building or validating +the package does not authorize installing it. ## Pinned inputs @@ -106,5 +110,22 @@ kept separate from driver control frames. The private daemon path uses UMDF request impersonation. Provisioning stores the invoking Windows user SID in the device instance; a daemon create request is accepted only when that SID is a member of the impersonated caller token. The public COM path does not use this private authorization check. -The application COM path, private CatHub adapter, and driver package must still be verified -together on the isolated driver-development target. + +## Development acceptance + +On 2026-08-22, with explicit operator authorization, the exact package produced from revision +`f641c2b028871b5ce90dc9bfcd912da393c14f76` passed the full local installed-driver harness on +Windows 11 Pro x64 build 26200. It was retained as `CatHub Virtual Serial Port (COM91)` at +`ROOT\PORTS\0000` using `oem86.inf`, driver version `16.25.49.354`. + +The run passed package-integrity and signature checks, Windows serial discovery, .NET +`SerialPort` finite timeouts, real CatHub TS-590 loopback traffic, 100 repeated bidirectional +queries, close/reopen, daemon loss and restart, and a UMDF device disable/enable cycle followed by +reconnection without reboot. All five automated client profiles passed 11 of 11 checks each +(55 of 55 total): HDSDR/OmniRig, N1MM CAT, ARCP-590, N1MM WinKeyer, and WKTools. + +The local evidence is retained in the ignored file `target/cathub-umdf-e2e-final.json`. That run +used the short-lived CatHub test certificate and Windows Test Signing, with Memory Integrity +running and integrity checks enabled; Secure Boot was disabled under the harness's explicit +exception. It proves the development transport works end to end, but it does not satisfy issue +#6's production clean-system signing gate or replace acceptance with the five actual applications. diff --git a/scripts/Test-UmdfEndToEnd.ps1 b/scripts/Test-UmdfEndToEnd.ps1 index 2972e90..9c10ac9 100644 --- a/scripts/Test-UmdfEndToEnd.ps1 +++ b/scripts/Test-UmdfEndToEnd.ps1 @@ -287,7 +287,7 @@ if (-not $isHyperVGuest -and -not $AllowLocalMachine) { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { - throw 'Run this test from an elevated PowerShell session inside the isolated VM.' + throw 'Run this test from an elevated PowerShell session on the authorized development target.' } $infPath = Join-Path $DriverPackage 'cathub_virtual_serial_umdf.inf' @@ -397,7 +397,7 @@ try { -not $results.security.device_guard -or 2 -notin @($results.security.device_guard.security_services_running) ) { - throw 'Memory Integrity (HVCI) is not reported as running on the isolated target.' + throw 'Memory Integrity (HVCI) is not reported as running on the development target.' } $results.certificate_root_install = Invoke-Captured certutil @(